mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 04:19:12 +00:00
refactor: migrate custom fields to transactions
refactor: extract functions to api domain refactor: strict custom field parsing refactor: remove rundown cache utilities refactor: directory restructure
This commit is contained in:
committed by
Carlos Valente
parent
b1d23467a2
commit
62c8319d70
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
|
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise<CustomFiel
|
|||||||
/**
|
/**
|
||||||
* Edits single custom field
|
* Edits single custom field
|
||||||
*/
|
*/
|
||||||
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
|
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
|
||||||
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
|
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes single custom field
|
* Deletes single custom field
|
||||||
*/
|
*/
|
||||||
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
|
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
|
||||||
const res = await axios.delete(`${customFieldsPath}/${label}`);
|
const res = await axios.delete(`${customFieldsPath}/${key}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoPencil, IoTrash } from 'react-icons/io5';
|
import { IoPencil, IoTrash } from 'react-icons/io5';
|
||||||
import { IconButton } from '@chakra-ui/react';
|
import { IconButton } from '@chakra-ui/react';
|
||||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||||
|
|
||||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||||
@@ -17,8 +17,8 @@ interface CustomFieldEntryProps {
|
|||||||
label: string;
|
label: string;
|
||||||
fieldKey: string;
|
fieldKey: string;
|
||||||
type: 'string' | 'image';
|
type: 'string' | 'image';
|
||||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
|
||||||
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
onDelete: (key: CustomFieldKey) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
import { IoAdd } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||||
|
|
||||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||||
import Info from '../../../../../common/components/info/Info';
|
import Info from '../../../../../common/components/info/Info';
|
||||||
@@ -31,14 +31,14 @@ export default function CustomFields() {
|
|||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
|
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
|
||||||
await editCustomField(label, customField);
|
await editCustomField(key, customField);
|
||||||
refetch();
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (label: string) => {
|
const handleDelete = async (key: CustomFieldKey) => {
|
||||||
try {
|
try {
|
||||||
await deleteCustomField(label);
|
await deleteCustomField(key);
|
||||||
refetch();
|
refetch();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
/** we do not handle errors here */
|
/** we do not handle errors here */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
@@ -14,7 +14,8 @@ import EventEditorEmpty from './EventEditorEmpty';
|
|||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
|
|
||||||
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
|
// any of the titles + custom field labels
|
||||||
|
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
|
||||||
|
|
||||||
interface EventEditorProps {
|
interface EventEditorProps {
|
||||||
event: OntimeEvent;
|
event: OntimeEvent;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
|
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types';
|
||||||
|
|
||||||
|
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addTrigger,
|
addTrigger,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
getAutomationTriggers,
|
getAutomationTriggers,
|
||||||
getAutomations,
|
getAutomations,
|
||||||
} from '../automation.dao.js';
|
} from '../automation.dao.js';
|
||||||
|
|
||||||
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -186,11 +189,9 @@ describe('editAutomation()', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteAutomation()', () => {
|
describe('deleteAutomation()', () => {
|
||||||
// saving the ID of the added automation
|
|
||||||
let firstAutomation: Automation;
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await deleteAll();
|
await deleteAll();
|
||||||
firstAutomation = await addAutomation({
|
await addAutomation({
|
||||||
title: 'test-osc',
|
title: 'test-osc',
|
||||||
filterRule: 'all',
|
filterRule: 'all',
|
||||||
filters: [],
|
filters: [],
|
||||||
@@ -198,35 +199,15 @@ describe('deleteAutomation()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove m automation from the list', async () => {
|
it('should remove an automation from the list', async () => {
|
||||||
const automations = getAutomations();
|
const automations = getAutomations();
|
||||||
expect(Object.keys(automations).length).toEqual(1);
|
expect(Object.keys(automations).length).toEqual(1);
|
||||||
|
|
||||||
await deleteAutomation(Object.keys(automations)[0]);
|
const rundown = makeRundown({});
|
||||||
|
const timedEventOrder: EntryId[] = [];
|
||||||
|
|
||||||
|
await deleteAutomation(rundown, timedEventOrder, Object.keys(automations)[0]);
|
||||||
const removed = getAutomations();
|
const removed = getAutomations();
|
||||||
expect(Object.keys(removed).length).toEqual(0);
|
expect(Object.keys(removed).length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not remove an automation which is in use', async () => {
|
|
||||||
const automations = getAutomations();
|
|
||||||
await addTrigger({
|
|
||||||
title: 'test-automation',
|
|
||||||
trigger: TimerLifeCycle.onLoad,
|
|
||||||
automationId: firstAutomation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const automationKeys = Object.keys(automations);
|
|
||||||
const automationId = automationKeys[0];
|
|
||||||
expect(automationId).toEqual(firstAutomation.id);
|
|
||||||
expect(automationKeys.length).toEqual(1);
|
|
||||||
expect(automations[automationId]).toMatchObject({
|
|
||||||
id: automationId,
|
|
||||||
title: 'test-osc',
|
|
||||||
filterRule: 'all',
|
|
||||||
filters: expect.any(Array),
|
|
||||||
outputs: expect.any(Array),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(deleteAutomation(automationId)).rejects.toThrowError();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
import { TimerLifeCycle } from 'ontime-types';
|
||||||
|
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||||
|
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||||
|
|
||||||
describe('parseTemplateNested()', () => {
|
describe('parseTemplateNested()', () => {
|
||||||
it('parses string with a single-level variable name', () => {
|
it('parses string with a single-level variable name', () => {
|
||||||
@@ -245,3 +247,53 @@ describe('test stringToOSCArgs()', () => {
|
|||||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isAutomationUsed()', () => {
|
||||||
|
it('returns the first event which uses an automation', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
id: 'trigger-1',
|
||||||
|
title: 'Trigger 1',
|
||||||
|
trigger: TimerLifeCycle.onClock,
|
||||||
|
automationId: 'test-automation',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const timedEventOrder = ['1'];
|
||||||
|
const automationId = 'test-automation';
|
||||||
|
|
||||||
|
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
|
||||||
|
expect(result).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns returns undefined if there are no matches', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
id: 'trigger-1',
|
||||||
|
title: 'Trigger 1',
|
||||||
|
trigger: TimerLifeCycle.onClock,
|
||||||
|
automationId: 'test-automation',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const timedEventOrder = ['1'];
|
||||||
|
const automationId = 'does-not-exist';
|
||||||
|
|
||||||
|
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { Request, Response } from 'express';
|
|||||||
|
|
||||||
import { oscServer } from '../../adapters/OscAdapter.js';
|
import { oscServer } from '../../adapters/OscAdapter.js';
|
||||||
|
|
||||||
|
import { getCurrentRundown, getRundownMetadata } from '../rundown/rundown.dao.js';
|
||||||
|
|
||||||
import * as automationDao from './automation.dao.js';
|
import * as automationDao from './automation.dao.js';
|
||||||
import * as automationService from './automation.service.js';
|
import * as automationService from './automation.service.js';
|
||||||
import { parseOutput } from './automation.validation.js';
|
import { parseOutput } from './automation.validation.js';
|
||||||
@@ -106,7 +108,10 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
|
|||||||
|
|
||||||
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
|
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
await automationDao.deleteAutomation(req.params.id);
|
const rundown = getCurrentRundown();
|
||||||
|
const { timedEventOrder } = getRundownMetadata();
|
||||||
|
|
||||||
|
await automationDao.deleteAutomation(rundown, timedEventOrder, req.params.id);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import type {
|
|||||||
Automation,
|
Automation,
|
||||||
AutomationDTO,
|
AutomationDTO,
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
|
EntryId,
|
||||||
NormalisedAutomation,
|
NormalisedAutomation,
|
||||||
|
Rundown,
|
||||||
Trigger,
|
Trigger,
|
||||||
TriggerDTO,
|
TriggerDTO,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { deleteAtIndex, generateId } from 'ontime-utils';
|
import { deleteAtIndex, generateId } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
|
|
||||||
|
import { isAutomationUsed } from './automation.utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a copy of the stored automation settings
|
* Gets a copy of the stored automation settings
|
||||||
@@ -133,7 +136,7 @@ export async function editAutomation(id: string, newAutomation: AutomationDTO):
|
|||||||
/**
|
/**
|
||||||
* Deletes a automation given its ID
|
* Deletes a automation given its ID
|
||||||
*/
|
*/
|
||||||
export async function deleteAutomation(id: string): Promise<void> {
|
export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], id: string): Promise<void> {
|
||||||
const automations = getAutomations();
|
const automations = getAutomations();
|
||||||
// ignore request if automation does not exist
|
// ignore request if automation does not exist
|
||||||
if (!Object.hasOwn(automations, id)) {
|
if (!Object.hasOwn(automations, id)) {
|
||||||
@@ -149,13 +152,9 @@ export async function deleteAutomation(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// prevent deleting a automation that is in use in events
|
// prevent deleting a automation that is in use in events
|
||||||
const events = getTimedEvents().filter(
|
const isInUse = isAutomationUsed(rundown, timedEventOrder, id);
|
||||||
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
|
if (isInUse) {
|
||||||
);
|
throw new Error(`Unable to delete automation used in event with ID ${isInUse}`);
|
||||||
if (events.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete automations[id];
|
delete automations[id];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
|
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||||
|
|
||||||
import { dbModel } from '../../models/dataModel.js';
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
import type { ErrorEmitter } from '../../utils/parser.js';
|
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
interface LegacyData extends Partial<DatabaseModel> {
|
interface LegacyData extends Partial<DatabaseModel> {
|
||||||
http?: unknown;
|
http?: unknown;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
|
import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, Rundown } from 'ontime-types';
|
||||||
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
|
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
|
||||||
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
|
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
|
||||||
|
|
||||||
@@ -195,3 +195,25 @@ export function isBooleanEquals(a: boolean, b: string): boolean {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks is an automation is used in a rundown
|
||||||
|
* TODO(v4): this currently only checks the current rundown, we will need to check all rundowns in the future
|
||||||
|
*/
|
||||||
|
export function isAutomationUsed(
|
||||||
|
rundown: Rundown,
|
||||||
|
timedEventOrder: EntryId[],
|
||||||
|
automationId: string,
|
||||||
|
): EntryId | undefined {
|
||||||
|
for (let i = 0; i < timedEventOrder.length; i++) {
|
||||||
|
const eventId = timedEventOrder[i];
|
||||||
|
const event = rundown.entries[eventId];
|
||||||
|
if (isOntimeEvent(event) && event.triggers) {
|
||||||
|
for (const trigger of event.triggers) {
|
||||||
|
if (trigger.automationId === automationId) {
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-97
@@ -1,101 +1,6 @@
|
|||||||
import { CustomFields, Settings, URLPreset } from 'ontime-types';
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import {
|
import { parseCustomFields, sanitiseCustomFields } from '../customFields.parser.js';
|
||||||
parseCustomFields,
|
|
||||||
parseProject,
|
|
||||||
parseSettings,
|
|
||||||
parseUrlPresets,
|
|
||||||
parseViewSettings,
|
|
||||||
sanitiseCustomFields,
|
|
||||||
} from '../parserFunctions.js';
|
|
||||||
|
|
||||||
describe('parseProject()', () => {
|
|
||||||
it('returns an a base model if nothing is given', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const result = parseProject({}, errorEmitter);
|
|
||||||
expect(result).toBeTypeOf('object');
|
|
||||||
expect(errorEmitter).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('test migration with adding the logo field v3.8.0', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const result = parseProject(
|
|
||||||
{
|
|
||||||
//@ts-expect-error -- checking migration when the logo field is added
|
|
||||||
project: {
|
|
||||||
title: 'title',
|
|
||||||
description: 'description',
|
|
||||||
publicUrl: 'publicUrl',
|
|
||||||
publicInfo: 'publicInfo',
|
|
||||||
backstageUrl: 'backstageUrl',
|
|
||||||
backstageInfo: 'backstageInfo',
|
|
||||||
custom: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
errorEmitter,
|
|
||||||
);
|
|
||||||
expect(result).toStrictEqual({
|
|
||||||
title: 'title',
|
|
||||||
description: 'description',
|
|
||||||
publicUrl: 'publicUrl',
|
|
||||||
publicInfo: 'publicInfo',
|
|
||||||
backstageUrl: 'backstageUrl',
|
|
||||||
backstageInfo: 'backstageInfo',
|
|
||||||
projectLogo: null,
|
|
||||||
custom: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseSettings()', () => {
|
|
||||||
it('throws if settings object does not exist', () => {
|
|
||||||
expect(() => parseSettings({})).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an a base model as long as we have the app version', () => {
|
|
||||||
const result = parseSettings({ settings: { version: '1' } as Settings });
|
|
||||||
expect(result).toBeTypeOf('object');
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
version: expect.any(String),
|
|
||||||
serverPort: 4001,
|
|
||||||
editorKey: null,
|
|
||||||
operatorKey: null,
|
|
||||||
timeFormat: '24',
|
|
||||||
language: 'en',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseViewSettings()', () => {
|
|
||||||
it('returns an a base model if nothing is given', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const result = parseViewSettings({}, errorEmitter);
|
|
||||||
expect(result).toBeTypeOf('object');
|
|
||||||
expect(errorEmitter).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseUrlPresets()', () => {
|
|
||||||
it('returns an a base model if nothing is given', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const result = parseUrlPresets({}, errorEmitter);
|
|
||||||
expect(result).toBeTypeOf('object');
|
|
||||||
expect(errorEmitter).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses data, skipping invalid results', () => {
|
|
||||||
const errorEmitter = vi.fn();
|
|
||||||
const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[];
|
|
||||||
const result = parseUrlPresets({ urlPresets }, errorEmitter);
|
|
||||||
expect(result.length).toEqual(1);
|
|
||||||
expect(result.at(0)).toMatchObject({
|
|
||||||
enabled: true,
|
|
||||||
alias: 'alias',
|
|
||||||
pathAndParams: 'ss',
|
|
||||||
});
|
|
||||||
expect(errorEmitter).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseCustomFields()', () => {
|
describe('parseCustomFields()', () => {
|
||||||
it('returns an a base model if nothing is given', () => {
|
it('returns an a base model if nothing is given', () => {
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { createCustomField, editCustomField, removeCustomField } from '../../services/rundown-service/rundownCache.js';
|
|
||||||
|
|
||||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
|
||||||
|
|
||||||
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
|
|
||||||
const customFields = getProjectCustomFields();
|
|
||||||
res.json(customFields);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function postCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
const newField = req.body as CustomField;
|
|
||||||
const allFields = await createCustomField(newField);
|
|
||||||
res.status(201).send(allFields);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
const oldLabel = req.params.label;
|
|
||||||
const { colour, type, label } = req.body;
|
|
||||||
const newFields = await editCustomField(oldLabel, { label, colour, type });
|
|
||||||
res.status(200).send(newFields);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expects { label: <label> }
|
|
||||||
export async function deleteCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
const fieldToDelete = req.params.label;
|
|
||||||
await removeCustomField(fieldToDelete);
|
|
||||||
res.sendStatus(204);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { DatabaseModel, CustomFields, CustomField } from 'ontime-types';
|
||||||
|
import { isAlphanumericWithSpace, customFieldLabelToKey } from 'ontime-utils';
|
||||||
|
|
||||||
|
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse customFields entry
|
||||||
|
*/
|
||||||
|
export function parseCustomFields(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): CustomFields {
|
||||||
|
if (typeof data.customFields !== 'object') {
|
||||||
|
emitError?.('No data found to import');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
console.log('Found Custom Fields, importing...');
|
||||||
|
|
||||||
|
const customFields = sanitiseCustomFields(data.customFields);
|
||||||
|
|
||||||
|
if (Object.keys(customFields).length !== Object.keys(data.customFields).length) {
|
||||||
|
emitError?.('Skipped invalid custom fields');
|
||||||
|
}
|
||||||
|
return customFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitiseCustomFields(data: object): CustomFields {
|
||||||
|
const newCustomFields: CustomFields = {};
|
||||||
|
|
||||||
|
for (const [_originalKey, field] of Object.entries(data)) {
|
||||||
|
if (!isValidField(field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAlphanumericWithSpace(field.label)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the key is always made from the label
|
||||||
|
const key = customFieldLabelToKey(field.label);
|
||||||
|
|
||||||
|
if (key in newCustomFields) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
newCustomFields[key] = {
|
||||||
|
type: field.type,
|
||||||
|
colour: field.colour,
|
||||||
|
label: field.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidField(data: unknown): data is CustomField {
|
||||||
|
return (
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
'label' in data &&
|
||||||
|
data.label !== '' &&
|
||||||
|
'colour' in data &&
|
||||||
|
typeof data.colour === 'string' &&
|
||||||
|
'type' in data &&
|
||||||
|
(data.type === 'string' || data.type === 'image')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCustomFields;
|
||||||
|
}
|
||||||
@@ -1,14 +1,49 @@
|
|||||||
import express from 'express';
|
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
|
||||||
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||||
|
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
|
||||||
|
|
||||||
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
|
|
||||||
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
|
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
router.get('/', getCustomFields);
|
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
|
||||||
|
const customFields = getProjectCustomFields();
|
||||||
|
res.json(customFields);
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/', validateCustomField, postCustomField);
|
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const newFields = await createCustomField(req.body as CustomField);
|
||||||
|
res.status(201).send(newFields);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/:label', validateEditCustomField, putCustomField);
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
|
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const customFields = await deleteCustomField(req.params.key);
|
||||||
|
res.status(200).send(customFields);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { body, param, validationResult } from 'express-validator';
|
|||||||
|
|
||||||
export const validateCustomField = [
|
export const validateCustomField = [
|
||||||
body('label')
|
body('label')
|
||||||
.exists()
|
|
||||||
.isString()
|
.isString()
|
||||||
.trim()
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
.custom((value) => {
|
.custom((value) => {
|
||||||
return isAlphanumericWithSpace(value);
|
return isAlphanumericWithSpace(value);
|
||||||
}),
|
}),
|
||||||
body('type').exists().isIn(['string', 'image']),
|
body('type').isIn(['string', 'image']),
|
||||||
body('colour').exists().isString().trim(),
|
body('colour').isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -22,16 +22,16 @@ export const validateCustomField = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const validateEditCustomField = [
|
export const validateEditCustomField = [
|
||||||
param('label').exists().isString().trim(),
|
param('key').isString().trim().notEmpty(),
|
||||||
body('label')
|
body('label')
|
||||||
.exists()
|
|
||||||
.isString()
|
.isString()
|
||||||
.trim()
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
.custom((value) => {
|
.custom((value) => {
|
||||||
return isAlphanumericWithSpace(value);
|
return isAlphanumericWithSpace(value);
|
||||||
}),
|
}),
|
||||||
body('type').exists().isIn(['string', 'image']),
|
body('type').isIn(['string', 'image']),
|
||||||
body('colour').exists().isString().trim(),
|
body('colour').isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -41,7 +41,7 @@ export const validateEditCustomField = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const validateDeleteCustomField = [
|
export const validateDeleteCustomField = [
|
||||||
param('label').exists().isString(),
|
param('key').isString().notEmpty(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/* eslint-disable no-console -- we are mocking the console */
|
||||||
|
|
||||||
|
import { demoDb } from '../../../models/demoProject.js';
|
||||||
|
|
||||||
|
import { parseDatabaseModel } from '../db.parser.js';
|
||||||
|
|
||||||
|
// mock data provider
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||||
|
return {
|
||||||
|
getDataProvider: vi.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||||
|
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('test parseDatabaseModel() with demo project (valid)', () => {
|
||||||
|
const filteredDemoProject = structuredClone(demoDb);
|
||||||
|
const { data } = parseDatabaseModel(filteredDemoProject);
|
||||||
|
|
||||||
|
it('has 17 events with 12 top level events', () => {
|
||||||
|
expect(data.rundowns.default.order.length).toBe(12);
|
||||||
|
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the same as the demo project since all data is valid', () => {
|
||||||
|
// @ts-expect-error -- its ok
|
||||||
|
delete filteredDemoProject.settings.version;
|
||||||
|
// @ts-expect-error -- its ok
|
||||||
|
delete data.settings.version;
|
||||||
|
expect(data).toMatchObject(filteredDemoProject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('test parseDatabaseModel() edge cases', () => {
|
||||||
|
it('skips unknown app and version settings', () => {
|
||||||
|
console.log = vi.fn();
|
||||||
|
const testData = {
|
||||||
|
settings: {
|
||||||
|
osc_port: 8888,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
|
expect(() => parseDatabaseModel(testData)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with invalid JSON', () => {
|
||||||
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
|
expect(() => parseDatabaseModel('some random dataset')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import multer, { type FileFilterCallback } from 'multer';
|
import multer, { type FileFilterCallback } from 'multer';
|
||||||
|
|
||||||
import { JSON_MIME } from '../../utils/parser.js';
|
|
||||||
import { storage } from '../../utils/upload.js';
|
import { storage } from '../../utils/upload.js';
|
||||||
|
|
||||||
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||||
if (file.mimetype.includes(JSON_MIME)) {
|
if (file.mimetype.includes('application/json')) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(null, false);
|
cb(null, false);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { DatabaseModel, LogOrigin } from 'ontime-types';
|
||||||
|
|
||||||
|
import { logger } from '../../classes/Logger.js';
|
||||||
|
|
||||||
|
import { parseAutomationSettings } from '../automation/automation.parser.js';
|
||||||
|
import { parseProjectData } from '../project-data/projectData.parser.js';
|
||||||
|
import { parseRundowns } from '../rundown/rundown.parser.js';
|
||||||
|
import { parseSettings } from '../settings/settings.parser.js';
|
||||||
|
import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
|
||||||
|
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
|
||||||
|
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||||
|
|
||||||
|
type ParsingError = {
|
||||||
|
context: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description handles parsing of ontime project file
|
||||||
|
* @param {object} jsonData - project file to be parsed
|
||||||
|
* @returns {object} - parsed object
|
||||||
|
*/
|
||||||
|
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
|
||||||
|
// we need to parse settings first to make sure the data is ours
|
||||||
|
// this may throw
|
||||||
|
const settings = parseSettings(jsonData);
|
||||||
|
|
||||||
|
const errors: ParsingError[] = [];
|
||||||
|
const makeEmitError = (context: string) => (message: string) => {
|
||||||
|
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
||||||
|
errors.push({ context, message });
|
||||||
|
};
|
||||||
|
|
||||||
|
// we need to parse the custom fields first so they can be used in validating events
|
||||||
|
const customFields = parseCustomFields(jsonData, makeEmitError('Custom Fields'));
|
||||||
|
const rundowns = parseRundowns(jsonData, customFields, makeEmitError('Rundowns'));
|
||||||
|
|
||||||
|
const data: DatabaseModel = {
|
||||||
|
rundowns,
|
||||||
|
project: parseProjectData(jsonData, makeEmitError('Project')),
|
||||||
|
settings,
|
||||||
|
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
|
||||||
|
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
|
||||||
|
customFields,
|
||||||
|
automation: parseAutomationSettings(jsonData),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data, errors };
|
||||||
|
}
|
||||||
+162
-289
@@ -1,294 +1,9 @@
|
|||||||
/* eslint-disable no-console -- we are mocking the console */
|
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
||||||
import { vi } from 'vitest';
|
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { CustomFields, DatabaseModel, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
import { getCustomFieldData, parseExcel } from '../excel.parser.js';
|
||||||
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { dbModel } from '../../models/dataModel.js';
|
import { dataFromExcelTemplate } from './mockData.js';
|
||||||
import { demoDb } from '../../models/demoProject.js';
|
|
||||||
|
|
||||||
import { getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
|
|
||||||
import { makeString } from '../parserUtils.js';
|
|
||||||
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
|
|
||||||
|
|
||||||
import { dataFromExcelTemplate } from './parser.mock-data.js';
|
|
||||||
|
|
||||||
// mock data provider
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
|
||||||
return {
|
|
||||||
getDataProvider: vi.fn().mockImplementation(() => {
|
|
||||||
return {
|
|
||||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test parseDatabaseModel() with demo project (valid)', () => {
|
|
||||||
const filteredDemoProject = structuredClone(demoDb);
|
|
||||||
const { data } = parseDatabaseModel(filteredDemoProject);
|
|
||||||
|
|
||||||
it('has 17 events with 12 top level events', () => {
|
|
||||||
expect(data.rundowns.default.order.length).toBe(12);
|
|
||||||
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is the same as the demo project since all data is valid', () => {
|
|
||||||
// @ts-expect-error -- its ok
|
|
||||||
delete filteredDemoProject.settings.version;
|
|
||||||
// @ts-expect-error -- its ok
|
|
||||||
delete data.settings.version;
|
|
||||||
expect(data).toMatchObject(filteredDemoProject);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test parseDatabaseModel() edge cases', () => {
|
|
||||||
it('skips unknown app and version settings', () => {
|
|
||||||
console.log = vi.fn();
|
|
||||||
const testData = {
|
|
||||||
settings: {
|
|
||||||
osc_port: 8888,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
||||||
expect(() => parseDatabaseModel(testData)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails with invalid JSON', () => {
|
|
||||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
||||||
expect(() => parseDatabaseModel('some random dataset')).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test aliases import', () => {
|
|
||||||
it('imports a well defined urlPreset', () => {
|
|
||||||
const testData = {
|
|
||||||
rundown: [],
|
|
||||||
settings: {
|
|
||||||
version: '2.0.0',
|
|
||||||
},
|
|
||||||
urlPresets: [
|
|
||||||
{
|
|
||||||
enabled: false,
|
|
||||||
alias: 'testalias',
|
|
||||||
pathAndParams: 'testpathAndParams',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as DatabaseModel;
|
|
||||||
|
|
||||||
const parsed = parseUrlPresets(testData);
|
|
||||||
expect(parsed.length).toBe(1);
|
|
||||||
|
|
||||||
// generates missing id
|
|
||||||
expect(parsed[0].alias).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test views import', () => {
|
|
||||||
it('imports data from file', () => {
|
|
||||||
const testData = {
|
|
||||||
rundown: [],
|
|
||||||
settings: {
|
|
||||||
version: '2.0.0',
|
|
||||||
},
|
|
||||||
viewSettings: {
|
|
||||||
normalColor: '#ffffffcc',
|
|
||||||
warningColor: '#FFAB33',
|
|
||||||
dangerColor: '#ED3333',
|
|
||||||
endMessage: '',
|
|
||||||
overrideStyles: false,
|
|
||||||
// known error: properties do not exist
|
|
||||||
notAthing: true,
|
|
||||||
},
|
|
||||||
// known error: views does not exist
|
|
||||||
views: {
|
|
||||||
overrideStyles: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const expectedParsedViewSettings = {
|
|
||||||
normalColor: '#ffffffcc',
|
|
||||||
warningColor: '#FFAB33',
|
|
||||||
dangerColor: '#ED3333',
|
|
||||||
freezeEnd: false,
|
|
||||||
endMessage: '',
|
|
||||||
overrideStyles: false,
|
|
||||||
};
|
|
||||||
// @ts-expect-error -- we know the above is incorrect
|
|
||||||
const parsed = parseViewSettings(testData);
|
|
||||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('imports defaults to model', () => {
|
|
||||||
const testData = {
|
|
||||||
rundown: [],
|
|
||||||
settings: {
|
|
||||||
version: '2.0.0',
|
|
||||||
},
|
|
||||||
} as unknown as DatabaseModel;
|
|
||||||
const parsed = parseViewSettings(testData);
|
|
||||||
expect(parsed).toStrictEqual(dbModel.viewSettings);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('makeString()', () => {
|
|
||||||
it('converts variables to string', () => {
|
|
||||||
const cases = [
|
|
||||||
{
|
|
||||||
val: 2,
|
|
||||||
expected: '2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: 2.22222222,
|
|
||||||
expected: '2.22222222',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: ['testing'],
|
|
||||||
expected: 'testing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: ' testing ',
|
|
||||||
expected: 'testing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: { doing: 'testing' },
|
|
||||||
expected: 'fallback',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val: undefined,
|
|
||||||
expected: 'fallback',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
cases.forEach(({ val, expected }) => {
|
|
||||||
const converted = makeString(val, 'fallback');
|
|
||||||
expect(converted).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
cue: 'cue',
|
|
||||||
title: 'title',
|
|
||||||
countToEnd: 'count to end',
|
|
||||||
isPublic: 'public',
|
|
||||||
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.customFields).toStrictEqual({
|
|
||||||
lighting: {
|
|
||||||
type: 'string',
|
|
||||||
colour: '',
|
|
||||||
label: 'lighting',
|
|
||||||
},
|
|
||||||
sound: {
|
|
||||||
type: 'string',
|
|
||||||
colour: '',
|
|
||||||
label: 'sound',
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
type: 'string',
|
|
||||||
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',
|
|
||||||
cue: 'cue',
|
|
||||||
title: 'title',
|
|
||||||
countToEnd: 'count to end',
|
|
||||||
isPublic: 'public',
|
|
||||||
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_label: 'excel label',
|
|
||||||
},
|
|
||||||
entryId: 'id',
|
|
||||||
} as ImportMap;
|
|
||||||
|
|
||||||
const customFields: CustomFields = {
|
|
||||||
lighting: { label: 'lx', type: 'string', colour: 'red' },
|
|
||||||
sound: { label: 'sound', type: 'string', colour: 'green' },
|
|
||||||
ontime_key: { label: 'ontime_label', type: 'string', colour: 'blue' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = getCustomFieldData(importMap, customFields);
|
|
||||||
expect(result.customFields).toStrictEqual({
|
|
||||||
lighting: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'lighting',
|
|
||||||
},
|
|
||||||
sound: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'green',
|
|
||||||
label: 'sound',
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
type: 'string',
|
|
||||||
colour: '',
|
|
||||||
label: 'video',
|
|
||||||
},
|
|
||||||
ontime_key: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'blue',
|
|
||||||
label: 'ontime_label',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// it is an inverted record of <importKey, ontimeKey>
|
|
||||||
expect(result.customFieldImportKeys).toStrictEqual({
|
|
||||||
lx: 'lighting',
|
|
||||||
sound: 'sound',
|
|
||||||
av: 'video',
|
|
||||||
'excel label': 'ontime_key',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseExcel()', () => {
|
describe('parseExcel()', () => {
|
||||||
it('parses the example file', () => {
|
it('parses the example file', () => {
|
||||||
@@ -707,3 +422,161 @@ describe('parseExcel()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
cue: 'cue',
|
||||||
|
title: 'title',
|
||||||
|
countToEnd: 'count to end',
|
||||||
|
isPublic: 'public',
|
||||||
|
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: 'string',
|
||||||
|
colour: '',
|
||||||
|
label: 'lighting',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
type: 'string',
|
||||||
|
colour: '',
|
||||||
|
label: 'sound',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
type: 'string',
|
||||||
|
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',
|
||||||
|
cue: 'cue',
|
||||||
|
title: 'title',
|
||||||
|
countToEnd: 'count to end',
|
||||||
|
isPublic: 'public',
|
||||||
|
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: 'string', colour: 'red' },
|
||||||
|
sound: { label: 'sound', type: 'string', colour: 'green' },
|
||||||
|
ontime_key: { label: 'ontime key', type: 'string', colour: 'blue' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getCustomFieldData(importMap, existingCustomFields);
|
||||||
|
expect(result.mergedCustomFields).toStrictEqual({
|
||||||
|
lighting: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
label: 'lighting',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'green',
|
||||||
|
label: 'sound',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
type: 'string',
|
||||||
|
colour: '',
|
||||||
|
label: 'video',
|
||||||
|
},
|
||||||
|
ontime_key: {
|
||||||
|
type: 'string',
|
||||||
|
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: 'string',
|
||||||
|
colour: '',
|
||||||
|
label: 'Lighting',
|
||||||
|
},
|
||||||
|
Sound: {
|
||||||
|
type: 'string',
|
||||||
|
colour: '',
|
||||||
|
label: 'Sound',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
type: 'string',
|
||||||
|
colour: '',
|
||||||
|
label: 'video',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// notice that the keys excel keys are lowercased
|
||||||
|
expect(result.customFieldImportKeys).toStrictEqual({
|
||||||
|
lx: 'Lighting',
|
||||||
|
sound: 'Sound',
|
||||||
|
av: 'video',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import multer, { type FileFilterCallback } from 'multer';
|
import multer, { type FileFilterCallback } from 'multer';
|
||||||
|
|
||||||
import { EXCEL_MIME } from '../../utils/parser.js';
|
|
||||||
import { storage } from '../../utils/upload.js';
|
import { storage } from '../../utils/upload.js';
|
||||||
|
|
||||||
|
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
|
||||||
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||||
if (file.mimetype.includes(EXCEL_MIME)) {
|
if (file.mimetype.includes(EXCEL_MIME)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
|
|||||||
@@ -1,80 +1,30 @@
|
|||||||
import {
|
|
||||||
customFieldLabelToKey,
|
|
||||||
customKeyFromLabel,
|
|
||||||
defaultImportMap,
|
|
||||||
generateId,
|
|
||||||
type ImportMap,
|
|
||||||
isKnownTimerType,
|
|
||||||
validateEndAction,
|
|
||||||
validateTimerType,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
DatabaseModel,
|
|
||||||
EntryCustomFields,
|
|
||||||
isOntimeBlock,
|
|
||||||
LogOrigin,
|
|
||||||
OntimeBlock,
|
|
||||||
OntimeEvent,
|
|
||||||
Rundown,
|
Rundown,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeBlock,
|
||||||
|
EntryCustomFields,
|
||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
|
isOntimeBlock,
|
||||||
TimerType,
|
TimerType,
|
||||||
|
CustomFieldKey,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
import {
|
||||||
|
ImportMap,
|
||||||
|
defaultImportMap,
|
||||||
|
generateId,
|
||||||
|
isKnownTimerType,
|
||||||
|
validateTimerType,
|
||||||
|
validateEndAction,
|
||||||
|
customFieldLabelToKey,
|
||||||
|
isAlphanumericWithSpace,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { Merge } from 'ts-essentials';
|
import { Merge } from 'ts-essentials';
|
||||||
|
|
||||||
import { parseAutomationSettings } from '../api-data/automation/automation.parser.js';
|
import { is } from '../../utils/is.js';
|
||||||
import { parseRundowns } from '../api-data/rundown/rundown.parser.js';
|
import { makeString } from '../../utils/parserUtils.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { parseExcelDate } from '../../utils/time.js';
|
||||||
|
|
||||||
import { makeString } from './parserUtils.js';
|
|
||||||
import { parseProject, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
|
|
||||||
import { parseExcelDate } from './time.js';
|
|
||||||
import { is } from './is.js';
|
|
||||||
|
|
||||||
export type ErrorEmitter = (message: string) => void;
|
|
||||||
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
||||||
export const JSON_MIME = 'application/json';
|
|
||||||
|
|
||||||
function parseBooleanString(value: unknown): boolean {
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// falsy values would be nullish or empty string
|
|
||||||
if (!value || typeof value !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return value.toLowerCase() !== 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCustomFieldData(
|
|
||||||
importMap: ImportMap,
|
|
||||||
existingCustomFields: CustomFields,
|
|
||||||
): {
|
|
||||||
customFields: CustomFields;
|
|
||||||
customFieldImportKeys: Record<keyof CustomFields, string>;
|
|
||||||
} {
|
|
||||||
const customFields = {};
|
|
||||||
const customFieldImportKeys: Record<string, string> = {};
|
|
||||||
|
|
||||||
for (const ontimeLabel in importMap.custom) {
|
|
||||||
const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel);
|
|
||||||
if (!ontimeKey) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const importLabel = importMap.custom[ontimeLabel].toLowerCase();
|
|
||||||
|
|
||||||
// @ts-expect-error -- we are sure that the key exists
|
|
||||||
customFields[ontimeKey] = {
|
|
||||||
type: 'string',
|
|
||||||
colour: ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '',
|
|
||||||
label: ontimeLabel,
|
|
||||||
};
|
|
||||||
customFieldImportKeys[importLabel] = ontimeKey;
|
|
||||||
}
|
|
||||||
return { customFields, customFieldImportKeys };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Excel array parser
|
* @description Excel array parser
|
||||||
@@ -102,7 +52,7 @@ export const parseExcel = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
|
const { mergedCustomFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
|
||||||
const rundown: Rundown = {
|
const rundown: Rundown = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
title: sheetName,
|
title: sheetName,
|
||||||
@@ -331,44 +281,68 @@ export const parseExcel = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
rundown,
|
rundown,
|
||||||
customFields,
|
customFields: mergedCustomFields,
|
||||||
rundownMetadata,
|
rundownMetadata,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParsingError = {
|
/**
|
||||||
context: string;
|
* Utility function infers a boolean from a string value
|
||||||
message: string;
|
*/
|
||||||
};
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description handles parsing of ontime project file
|
* Receives an import map which contains custom field labels and a custom fields object
|
||||||
* @param {object} jsonData - project file to be parsed
|
* the result importkeys is an inverted record of <importKey, ontimeKey>
|
||||||
* @returns {object} - parsed object
|
* 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 parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
|
export function getCustomFieldData(
|
||||||
// we need to parse settings first to make sure the data is ours
|
importMap: ImportMap,
|
||||||
// this may throw
|
existingCustomFields: CustomFields,
|
||||||
const settings = parseSettings(jsonData);
|
): {
|
||||||
|
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> = {};
|
||||||
|
|
||||||
const errors: ParsingError[] = [];
|
for (const ontimeLabel in importMap.custom) {
|
||||||
const makeEmitError = (context: string) => (message: string) => {
|
// if the label is not valid, we skip the import
|
||||||
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
if (!isAlphanumericWithSpace(ontimeLabel)) {
|
||||||
errors.push({ context, message });
|
continue;
|
||||||
};
|
}
|
||||||
|
|
||||||
// we need to parse the custom fields first so they can be used in validating events
|
// generate a key for the custom field
|
||||||
const { rundowns, customFields } = parseRundowns(jsonData, makeEmitError('Rundown'));
|
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 ?? '';
|
||||||
|
|
||||||
const data: DatabaseModel = {
|
// 1. add the custom field to the merged custom fields
|
||||||
rundowns,
|
mergedCustomFields[keyInCustomFields] = {
|
||||||
project: parseProject(jsonData, makeEmitError('Project')),
|
type: 'string', // we currently only support string custom fields
|
||||||
settings,
|
colour: maybeExistingColour,
|
||||||
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
|
label: ontimeLabel,
|
||||||
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
|
};
|
||||||
customFields,
|
|
||||||
automation: parseAutomationSettings(jsonData),
|
|
||||||
};
|
|
||||||
|
|
||||||
return { data, errors };
|
// 2. add the column to the import keys
|
||||||
|
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
|
||||||
|
}
|
||||||
|
return { mergedCustomFields, customFieldImportKeys };
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,13 @@ import { existsSync } from 'fs';
|
|||||||
import xlsx from 'xlsx';
|
import xlsx from 'xlsx';
|
||||||
import type { WorkBook } from 'xlsx';
|
import type { WorkBook } from 'xlsx';
|
||||||
|
|
||||||
import { parseExcel } from '../../utils/parser.js';
|
import { deleteFile } from '../../utils/fileManagement.js';
|
||||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
|
||||||
import { deleteFile } from '../../utils/parserUtils.js';
|
|
||||||
|
|
||||||
import { parseRundown } from '../rundown/rundown.parser.js';
|
import { parseRundown } from '../rundown/rundown.parser.js';
|
||||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||||
|
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||||
|
|
||||||
|
import { parseExcel } from './excel.parser.js';
|
||||||
|
|
||||||
let excelData: WorkBook = xlsx.utils.book_new();
|
let excelData: WorkBook = xlsx.utils.book_new();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { router as automationsRouter } from './automation/automation.router.js';
|
|||||||
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
|
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
|
||||||
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
||||||
import { router as dbRouter } from './db/db.router.js';
|
import { router as dbRouter } from './db/db.router.js';
|
||||||
import { router as projectRouter } from './project/project.router.js';
|
import { router as projectRouter } from './project-data/projectData.router.js';
|
||||||
import { router as rundownRouter } from './rundown/rundown.router.js';
|
import { router as rundownRouter } from './rundown/rundown.router.js';
|
||||||
import { router as settingsRouter } from './settings/settings.router.js';
|
import { router as settingsRouter } from './settings/settings.router.js';
|
||||||
import { router as sheetsRouter } from './sheets/sheets.router.js';
|
import { router as sheetsRouter } from './sheets/sheets.router.js';
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { parseProjectData } from '../projectData.parser.js';
|
||||||
|
|
||||||
|
describe('parseProjectData()', () => {
|
||||||
|
it('returns an a base model if nothing is given', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const result = parseProjectData({}, errorEmitter);
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
expect(errorEmitter).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { removeUndefined } from '../../utils/parserUtils.js';
|
import { removeUndefined } from '../../utils/parserUtils.js';
|
||||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||||
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
|
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
|
||||||
import * as projectDao from './project.dao.js';
|
import * as projectDao from './projectData.dao.js';
|
||||||
|
|
||||||
export function getProjectData(_req: Request, res: Response<ProjectData>) {
|
export function getProjectData(_req: Request, res: Response<ProjectData>) {
|
||||||
res.json(projectDao.getProjectData());
|
res.json(projectDao.getProjectData());
|
||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
import { ProjectData } from 'ontime-types';
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { DatabaseModel, ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
|
import { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse event portion of an entry
|
||||||
|
*/
|
||||||
|
export function parseProjectData(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ProjectData {
|
||||||
|
if (!data.project) {
|
||||||
|
emitError?.('No data found to import');
|
||||||
|
return { ...dbModel.project };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found project data, importing...');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.project.title ?? dbModel.project.title,
|
||||||
|
description: data.project.description ?? dbModel.project.description,
|
||||||
|
publicUrl: data.project.publicUrl ?? dbModel.project.publicUrl,
|
||||||
|
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
|
||||||
|
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
|
||||||
|
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
|
||||||
|
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
|
||||||
|
custom: data.project.custom ?? dbModel.project.custom,
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import { getProjectData, postProjectData } from './project.controller.js';
|
import { getProjectData, postProjectData } from './projectData.controller.js';
|
||||||
import { projectSanitiser } from './project.validation.js';
|
import { projectSanitiser } from './projectData.validation.js';
|
||||||
import { uploadImageFile } from '../db/db.middleware.js';
|
import { uploadImageFile } from '../db/db.middleware.js';
|
||||||
import { postProjectLogo } from '../db/db.controller.js';
|
import { postProjectLogo } from '../db/db.controller.js';
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types';
|
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown, CustomField } from 'ontime-types';
|
||||||
|
|
||||||
import { defaultRundown } from '../../../models/dataModel.js';
|
import { defaultRundown } from '../../../models/dataModel.js';
|
||||||
|
|
||||||
const baseEvent = {
|
const baseEvent = {
|
||||||
@@ -46,6 +47,15 @@ export function makeRundown(patch: Partial<Rundown>): Rundown {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function makeCustomField(patch: Partial<CustomField>): CustomField {
|
||||||
|
return {
|
||||||
|
type: 'string',
|
||||||
|
colour: '#000000',
|
||||||
|
label: 'Custom Field',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility to generate a rundown of OntimeEvents form partial objects
|
* Utility to generate a rundown of OntimeEvents form partial objects
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { makeOntimeEvent, makeRundown, makeOntimeBlock, makeOntimeDelay } from '../__mocks__/rundown.mocks.js';
|
import {
|
||||||
|
makeOntimeEvent,
|
||||||
|
makeRundown,
|
||||||
|
makeOntimeBlock,
|
||||||
|
makeOntimeDelay,
|
||||||
|
makeCustomField,
|
||||||
|
} from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import { createTransaction, processRundown, rundownCache, rundownMutation } from '../rundown.dao.js';
|
import {
|
||||||
|
createTransaction,
|
||||||
|
customFieldMutation,
|
||||||
|
processRundown,
|
||||||
|
rundownCache,
|
||||||
|
rundownMutation,
|
||||||
|
} from '../rundown.dao.js';
|
||||||
import { demoDb } from '../../../models/demoProject.js';
|
import { demoDb } from '../../../models/demoProject.js';
|
||||||
import { ProcessedRundownMetadata } from '../../../services/rundown-service/rundownCache.utils.js';
|
import type { AssignedMap } from '../rundown.types.js';
|
||||||
|
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
|
||||||
|
|
||||||
|
const setRundownMock = vi.fn();
|
||||||
|
const setCustomFieldsMock = vi.fn();
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||||
return {
|
return {
|
||||||
getDataProvider: vi.fn().mockImplementation(() => {
|
getDataProvider: vi.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
setRundown: vi.fn().mockImplementation(() => undefined),
|
setRundown: setRundownMock,
|
||||||
|
setCustomFields: setCustomFieldsMock,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -24,17 +41,39 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createTransaction', () => {
|
describe('createTransaction', () => {
|
||||||
it('should return a snapshot of the cached rundown and an commit function', () => {
|
beforeEach(() => {
|
||||||
const { rundown, commit } = createTransaction();
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a snapshot of the cached data and an commit function', () => {
|
||||||
|
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
|
||||||
expect(rundown).toBeDefined();
|
expect(rundown).toBeDefined();
|
||||||
|
expect(customFields).toBeDefined();
|
||||||
expect(typeof commit).toBe('function');
|
expect(typeof commit).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the updated rundown after commit is called and update the db', () => {
|
it('should return the updated data after commit is called and writes are scheduled', () => {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
|
||||||
rundown.title = 'Another Title';
|
rundown.title = 'Another Title';
|
||||||
|
customFields['newField'] = {
|
||||||
|
label: 'New Field',
|
||||||
|
type: 'string',
|
||||||
|
colour: 'blue',
|
||||||
|
};
|
||||||
|
|
||||||
const updated = commit();
|
const updated = commit();
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
expect(updated.rundown.title).toBe('Another Title');
|
expect(updated.rundown.title).toBe('Another Title');
|
||||||
|
expect(updated.customFields).toHaveProperty('newField');
|
||||||
|
|
||||||
|
expect(setRundownMock).toHaveBeenCalledOnce();
|
||||||
|
expect(setCustomFieldsMock).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1568,3 +1607,119 @@ describe('rundownMutation.ungroup()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('customFieldMutation.add()', () => {
|
||||||
|
it('adds a custom field given object', () => {
|
||||||
|
const customFields = {
|
||||||
|
one: makeCustomField({ label: 'one' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
customFieldMutation.add(customFields, 'two', makeCustomField({ label: 'two' }));
|
||||||
|
|
||||||
|
expect(customFields).toMatchObject({
|
||||||
|
one: { label: 'one' },
|
||||||
|
two: { label: 'two' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('customFieldMutation.edit()', () => {
|
||||||
|
it('changes properties of an existing custom field', () => {
|
||||||
|
const customFields = {
|
||||||
|
one: makeCustomField({ label: 'one', colour: 'blue' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
customFieldMutation.edit(customFields, 'one', customFields.one, { colour: 'red' });
|
||||||
|
|
||||||
|
expect(customFields).toMatchObject({
|
||||||
|
one: { label: 'one', colour: 'red' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing the label makes a new key', () => {
|
||||||
|
const customFields = {
|
||||||
|
one: makeCustomField({ label: 'one', colour: 'blue' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { oldKey, newKey } = customFieldMutation.edit(customFields, 'one', customFields.one, {
|
||||||
|
label: 'two',
|
||||||
|
colour: 'red',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(oldKey).toBe('one');
|
||||||
|
expect(newKey).not.toEqual(oldKey);
|
||||||
|
|
||||||
|
expect(customFields).toMatchObject({
|
||||||
|
[oldKey]: { label: 'one', colour: 'blue' },
|
||||||
|
[newKey]: { label: 'two', colour: 'red' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('customFieldMutation.remove()', () => {
|
||||||
|
it('deletes a custom field from the object', () => {
|
||||||
|
const customFields = {
|
||||||
|
one: makeCustomField({ label: 'one', colour: 'blue' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
customFieldMutation.remove(customFields, 'one');
|
||||||
|
|
||||||
|
expect(customFields).not.toHaveProperty('one');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('customFieldMutation.renameUsages()', () => {
|
||||||
|
it('renames all custom field entries in a given rundown', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['1', '2', '3'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }),
|
||||||
|
'3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assigned: AssignedMap = {
|
||||||
|
one: ['1', '2'],
|
||||||
|
two: ['3'],
|
||||||
|
};
|
||||||
|
|
||||||
|
customFieldMutation.renameUsages(rundown, assigned, '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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('customFieldMutation.removeUsages()', () => {
|
||||||
|
it('deletes all custom field entries in a given rundown', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['1', '2', '3'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }),
|
||||||
|
'3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assigned: AssignedMap = {
|
||||||
|
one: ['1', '2'],
|
||||||
|
two: ['3'],
|
||||||
|
};
|
||||||
|
|
||||||
|
customFieldMutation.removeUsages(rundown, assigned, '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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
|
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown, CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { defaultRundown } from '../../../models/dataModel.js';
|
import { defaultRundown } from '../../../models/dataModel.js';
|
||||||
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import { parseRundowns, parseRundown } from '../rundown.parser.js';
|
import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js';
|
||||||
|
|
||||||
describe('parseRundowns()', () => {
|
describe('parseRundowns()', () => {
|
||||||
it('returns a default project rundown if nothing is given', () => {
|
it('returns a default project rundown if nothing is given', () => {
|
||||||
const errorEmitter = vi.fn();
|
const errorEmitter = vi.fn();
|
||||||
const result = parseRundowns({}, errorEmitter);
|
const result = parseRundowns({}, {}, errorEmitter);
|
||||||
expect(result.customFields).toEqual({});
|
expect(result).toStrictEqual({ default: defaultRundown });
|
||||||
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
|
|
||||||
// one for not having custom fields
|
// one for not having custom fields
|
||||||
// one for not having a rundown
|
// one for not having a rundown
|
||||||
expect(errorEmitter).toHaveBeenCalledTimes(2);
|
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ensures the rundown IDs are consistent', () => {
|
it('ensures the rundown IDs are consistent', () => {
|
||||||
@@ -27,14 +26,14 @@ describe('parseRundowns()', () => {
|
|||||||
'3': r2,
|
'3': r2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{},
|
||||||
errorEmitter,
|
errorEmitter,
|
||||||
);
|
);
|
||||||
expect(result.rundowns).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
'1': r1,
|
'1': r1,
|
||||||
'2': r2,
|
'2': r2,
|
||||||
});
|
});
|
||||||
// one for not having a rundown
|
expect(errorEmitter).toHaveBeenCalledTimes(0);
|
||||||
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,6 +182,37 @@ describe('parseRundown()', () => {
|
|||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses customFields', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['1', '2'],
|
||||||
|
flatOrder: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', custom: { lighting: 'on' } }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', custom: { sound: 'loud' } }),
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
const customFields: CustomFields = {
|
||||||
|
lighting: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
label: 'lighting',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
label: 'sound',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, customFields);
|
||||||
|
expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'on' });
|
||||||
|
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
|
||||||
|
});
|
||||||
|
|
||||||
it('parses events nested in blocks', () => {
|
it('parses events nested in blocks', () => {
|
||||||
const rundown = {
|
const rundown = {
|
||||||
id: 'test',
|
id: 'test',
|
||||||
@@ -203,3 +233,50 @@ describe('parseRundown()', () => {
|
|||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const customFields = {
|
||||||
|
lighting: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
label: 'lighting',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
label: 'sound',
|
||||||
|
},
|
||||||
|
} as CustomFields;
|
||||||
|
|
||||||
|
const event = makeOntimeEvent({
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
id: '2',
|
||||||
|
timeStart: 0,
|
||||||
|
linkStart: true,
|
||||||
|
custom: {
|
||||||
|
lighting: 'on',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const assignedCustomFields = {};
|
||||||
|
|
||||||
|
const result = handleCustomField(customFields, event, assignedCustomFields);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
|
||||||
|
expect(event.custom).toStrictEqual({
|
||||||
|
lighting: 'on',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { createEvent, deleteById, doesInvalidateMetadata, hasChanges } from '../rundown.utils.js';
|
import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js';
|
||||||
|
import { makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
it('validates a good object', () => {
|
it('validates a good object', () => {
|
||||||
@@ -130,7 +132,7 @@ describe('hasChanges()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteById', () => {
|
describe('deleteById()', () => {
|
||||||
it('should delete the first instance of the specified ID from the array', () => {
|
it('should delete the first instance of the specified ID from the array', () => {
|
||||||
const array = ['id1', 'id2', 'id3', 'id4'];
|
const array = ['id1', 'id2', 'id3', 'id4'];
|
||||||
const result = deleteById(array, 'id2');
|
const result = deleteById(array, 'id2');
|
||||||
@@ -156,3 +158,83 @@ describe('deleteById', () => {
|
|||||||
expect(result).toStrictEqual(['id1', 'id2', 'id3']);
|
expect(result).toStrictEqual(['id1', 'id2', 'id3']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('calculateDayOffset()', () => {
|
||||||
|
it('returns 0 if there is no previous event', () => {
|
||||||
|
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 if the previous event duration is 0', () => {
|
||||||
|
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 if event starts after previous', () => {
|
||||||
|
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1 if event starts before previous', () => {
|
||||||
|
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1 if event starts at the same time as one before', () => {
|
||||||
|
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should account for an event that crossed midnight and there is a overlap', () => {
|
||||||
|
expect(
|
||||||
|
calculateDayOffset(
|
||||||
|
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
|
||||||
|
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should account for an event that crossed midnight and there is a gap', () => {
|
||||||
|
expect(
|
||||||
|
calculateDayOffset(
|
||||||
|
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||||
|
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
|
||||||
|
expect(
|
||||||
|
calculateDayOffset(
|
||||||
|
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||||
|
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should account for an event that finishes exactly at midnight', () => {
|
||||||
|
expect(
|
||||||
|
calculateDayOffset(
|
||||||
|
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||||
|
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInsertAfterId()', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
flatOrder: ['a', 'b', 'c', 'd'],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns afterId if provided', () => {
|
||||||
|
expect(getInsertAfterId(rundown, 'b')).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the previous id before beforeId if provided', () => {
|
||||||
|
expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
||||||
|
expect(getInsertAfterId(rundown)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if beforeId is not found', () => {
|
||||||
|
expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CustomField,
|
||||||
|
CustomFieldKey,
|
||||||
CustomFields,
|
CustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
@@ -23,13 +25,11 @@ import {
|
|||||||
PatchWithId,
|
PatchWithId,
|
||||||
Rundown,
|
Rundown,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { insertAtIndex } from 'ontime-utils';
|
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
|
||||||
|
|
||||||
import { makeRundownMetadata, ProcessedRundownMetadata } from '../../services/rundown-service/rundownCache.utils.js';
|
|
||||||
import { customFieldChangelog } from '../../services/rundown-service/rundownCache.js';
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
|
||||||
import {
|
import {
|
||||||
applyPatchToEntry,
|
applyPatchToEntry,
|
||||||
cloneBlock,
|
cloneBlock,
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
getUniqueId,
|
getUniqueId,
|
||||||
} from './rundown.utils.js';
|
} from './rundown.utils.js';
|
||||||
|
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The currently loaded rundown in cache
|
* The currently loaded rundown in cache
|
||||||
@@ -62,8 +63,15 @@ let rundownMetadata: RundownMetadata = {
|
|||||||
playableEventOrder: [],
|
playableEventOrder: [],
|
||||||
timedEventOrder: [],
|
timedEventOrder: [],
|
||||||
flatEntryOrder: [],
|
flatEntryOrder: [],
|
||||||
|
};
|
||||||
|
|
||||||
assignedCustomFields: {},
|
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: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,47 +81,103 @@ let rundownMetadata: RundownMetadata = {
|
|||||||
let projectCustomFields: CustomFields = {};
|
let projectCustomFields: CustomFields = {};
|
||||||
|
|
||||||
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
|
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
|
||||||
|
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
|
||||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||||
|
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
||||||
|
|
||||||
export function createTransaction() {
|
type Transaction = {
|
||||||
const rundown = structuredClone(cachedRundown);
|
customFields: CustomFields;
|
||||||
const customFields = projectCustomFields;
|
customFieldsMetadata: Readonly<CustomFieldsMetadata>;
|
||||||
|
rundown: Rundown;
|
||||||
|
rundownMetadata: Readonly<RundownMetadata>;
|
||||||
|
|
||||||
|
commit: (shouldProcess?: boolean) => {
|
||||||
|
rundown: Readonly<Rundown>;
|
||||||
|
rundownMetadata: Readonly<RundownMetadata>;
|
||||||
|
customFields: Readonly<CustomFields>;
|
||||||
|
revision: Readonly<number>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransactionOptions = {
|
||||||
|
mutableRundown?: boolean;
|
||||||
|
mutableCustomFields?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTransaction(options: TransactionOptions): Transaction {
|
||||||
|
const rundown = options.mutableRundown ? structuredClone(cachedRundown) : cachedRundown;
|
||||||
|
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a mutated rundown to the cache
|
||||||
|
* @param shouldProcess - whether the rundown should be processed after the commit
|
||||||
|
* Some edit mutations, and custom field changes do not require processing
|
||||||
|
*/
|
||||||
function commit(shouldProcess: boolean = true) {
|
function commit(shouldProcess: boolean = true) {
|
||||||
// schedule a database update
|
// if the rundown is mutable we persist the changes
|
||||||
setImmediate(async () => {
|
if (options.mutableRundown) {
|
||||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
// schedule a database update
|
||||||
});
|
setImmediate(async () => {
|
||||||
|
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||||
|
});
|
||||||
|
|
||||||
const revision = rundown.revision + 1;
|
// increment the revision number
|
||||||
cachedRundown.revision = revision;
|
cachedRundown.revision = cachedRundown.revision + 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some mutations do not require processing the rundown
|
* Some mutations do not require processing the rundown
|
||||||
* We simply increment the revision and return the rundown
|
* We simply increment the revision and return the rundown
|
||||||
*/
|
*/
|
||||||
if (!shouldProcess) {
|
if (!shouldProcess) {
|
||||||
cachedRundown.entries = rundown.entries;
|
cachedRundown.title = rundown.title;
|
||||||
cachedRundown.order = rundown.order;
|
cachedRundown.entries = rundown.entries;
|
||||||
cachedRundown.flatOrder = rundown.flatOrder;
|
cachedRundown.order = rundown.order;
|
||||||
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
cachedRundown.flatOrder = rundown.flatOrder;
|
||||||
|
return {
|
||||||
|
rundown: cachedRundown,
|
||||||
|
rundownMetadata, // metadata doesnt change as long as we dont process the rundown
|
||||||
|
customFields: projectCustomFields,
|
||||||
|
revision: cachedRundown.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
cachedRundown.title = rundown.title;
|
||||||
|
cachedRundown.entries = entries;
|
||||||
|
cachedRundown.order = order;
|
||||||
|
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||||
|
customFieldsMetadata.assigned = assignedCustomFields;
|
||||||
|
rundownMetadata = metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedData = processRundown(rundown, projectCustomFields);
|
// if the customFields are mutable we persist the changes
|
||||||
// update the cache values
|
if (options.mutableCustomFields) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
// schedule a database update
|
||||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
setImmediate(async () => {
|
||||||
cachedRundown.entries = entries;
|
await getDataProvider().setCustomFields(projectCustomFields);
|
||||||
cachedRundown.order = order;
|
});
|
||||||
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
|
||||||
rundownMetadata = metadata;
|
|
||||||
|
|
||||||
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
projectCustomFields = customFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rundown: cachedRundown,
|
||||||
|
rundownMetadata,
|
||||||
|
customFields: projectCustomFields,
|
||||||
|
revision: cachedRundown.revision,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customFields,
|
customFields,
|
||||||
|
customFieldsMetadata,
|
||||||
rundown,
|
rundown,
|
||||||
|
rundownMetadata,
|
||||||
commit,
|
commit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -329,7 +393,7 @@ function applyDelay(rundown: Rundown, delay: OntimeDelay) {
|
|||||||
/**
|
/**
|
||||||
* Swaps the data between two events
|
* Swaps the data between two events
|
||||||
* The schedule and metadata are preserved
|
* The schedule and metadata are preserved
|
||||||
* TODO: this logic is for now duplcate of Ontime-Utils.swapEventData
|
* TODO: this logic is for now duplicate of Ontime-Utils.swapEventData
|
||||||
*/
|
*/
|
||||||
function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
|
function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
|
||||||
rundown.entries[eventFrom.id] = {
|
rundown.entries[eventFrom.id] = {
|
||||||
@@ -484,6 +548,100 @@ export const rundownMutation = {
|
|||||||
ungroup,
|
ungroup,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new custom field to the object and returns it
|
||||||
|
*/
|
||||||
|
function customFieldAdd(customFields: CustomFields, key: CustomFieldKey, newCustomField: CustomField): CustomFields {
|
||||||
|
customFields[key] = {
|
||||||
|
label: newCustomField.label,
|
||||||
|
type: newCustomField.type,
|
||||||
|
colour: newCustomField.colour,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { [key]: newCustomField };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits an existing custom field
|
||||||
|
*/
|
||||||
|
function customFieldEdit(
|
||||||
|
customFields: CustomFields,
|
||||||
|
key: CustomFieldKey,
|
||||||
|
existingField: CustomField,
|
||||||
|
newField: Partial<CustomField>,
|
||||||
|
): { oldKey: CustomFieldKey; newKey: CustomFieldKey } {
|
||||||
|
// calculate the key in case it has changed
|
||||||
|
const newKey = newField?.label ? customFieldLabelToKey(newField.label ?? key) : key;
|
||||||
|
|
||||||
|
// patch the new field and replace the reference in the object
|
||||||
|
customFields[newKey] = { ...existingField, ...newField };
|
||||||
|
|
||||||
|
return { oldKey: key, newKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a custom field from the object
|
||||||
|
*/
|
||||||
|
function customFieldRemove(customFields: CustomFields, key: CustomFieldKey) {
|
||||||
|
delete customFields[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames a custom field key in all the rundown entries that use it
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes data for a custom field from all the entries that use it
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customFieldMutation = {
|
||||||
|
add: customFieldAdd,
|
||||||
|
edit: customFieldEdit,
|
||||||
|
remove: customFieldRemove,
|
||||||
|
renameUsages: customFieldRenameUsages,
|
||||||
|
removeUsages: customFieldRemoveUsages,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expose function to add an initial rundown to the system
|
* Expose function to add an initial rundown to the system
|
||||||
*/
|
*/
|
||||||
@@ -498,11 +656,13 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
|||||||
projectCustomFields = customFields;
|
projectCustomFields = customFields;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
||||||
|
processedData;
|
||||||
cachedRundown.entries = entries;
|
cachedRundown.entries = entries;
|
||||||
cachedRundown.order = order;
|
cachedRundown.order = order;
|
||||||
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||||
cachedRundown.revision = rundown.revision;
|
cachedRundown.revision = rundown.revision;
|
||||||
|
customFieldsMetadata.assigned = assignedCustomFields;
|
||||||
rundownMetadata = metadata;
|
rundownMetadata = metadata;
|
||||||
|
|
||||||
// defer writing to the database
|
// defer writing to the database
|
||||||
@@ -533,7 +693,7 @@ export function processRundown(
|
|||||||
initialRundown: Readonly<Rundown>,
|
initialRundown: Readonly<Rundown>,
|
||||||
customFields: Readonly<CustomFields>,
|
customFields: Readonly<CustomFields>,
|
||||||
): ProcessedRundownMetadata {
|
): ProcessedRundownMetadata {
|
||||||
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
|
const { process, getMetadata } = makeRundownMetadata(customFields);
|
||||||
|
|
||||||
for (let i = 0; i < initialRundown.order.length; i++) {
|
for (let i = 0; i < initialRundown.order.length; i++) {
|
||||||
// we assign a reference to the current entry, this will be mutated in place
|
// we assign a reference to the current entry, this will be mutated in place
|
||||||
|
|||||||
@@ -9,15 +9,21 @@ import {
|
|||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
|
CustomFieldKey,
|
||||||
|
EntryId,
|
||||||
|
OntimeEntry,
|
||||||
|
PlayableEvent,
|
||||||
|
RundownEntries,
|
||||||
|
isPlayableEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { isObjectEmpty, generateId } from 'ontime-utils';
|
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||||
|
|
||||||
import { defaultRundown } from '../../models/dataModel.js';
|
import { defaultRundown } from '../../models/dataModel.js';
|
||||||
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
|
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
|
||||||
import { ErrorEmitter } from '../../utils/parser.js';
|
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
|
||||||
|
|
||||||
import { createEvent } from './rundown.utils.js';
|
import { calculateDayOffset, createEvent } from './rundown.utils.js';
|
||||||
|
import { RundownMetadata } from './rundown.types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a rundowns object along with the project custom fields
|
* Parse a rundowns object along with the project custom fields
|
||||||
@@ -25,21 +31,16 @@ import { createEvent } from './rundown.utils.js';
|
|||||||
*/
|
*/
|
||||||
export function parseRundowns(
|
export function parseRundowns(
|
||||||
data: Partial<DatabaseModel>,
|
data: Partial<DatabaseModel>,
|
||||||
|
parsedCustomFields: Readonly<CustomFields>,
|
||||||
emitError?: ErrorEmitter,
|
emitError?: ErrorEmitter,
|
||||||
): { customFields: CustomFields; rundowns: ProjectRundowns } {
|
): ProjectRundowns {
|
||||||
// check custom fields first
|
|
||||||
const parsedCustomFields = parseCustomFields(data, emitError);
|
|
||||||
|
|
||||||
// ensure there is always a rundown to import
|
// ensure there is always a rundown to import
|
||||||
// this is important since the rest of the app assumes this exist
|
// this is important since the rest of the app assumes this exist
|
||||||
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
|
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
|
||||||
emitError?.('No data found to import');
|
emitError?.('No data found to import');
|
||||||
return {
|
return {
|
||||||
customFields: parsedCustomFields,
|
[defaultRundown.id]: {
|
||||||
rundowns: {
|
...defaultRundown,
|
||||||
default: {
|
|
||||||
...defaultRundown,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -55,7 +56,7 @@ export function parseRundowns(
|
|||||||
parsedRundowns[parsedRundown.id] = parsedRundown;
|
parsedRundowns[parsedRundown.id] = parsedRundown;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
|
return parsedRundowns;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +82,7 @@ export function parseRundown(
|
|||||||
const entryId = rundown.order[i];
|
const entryId = rundown.order[i];
|
||||||
const event = rundown.entries[entryId];
|
const event = rundown.entries[entryId];
|
||||||
|
|
||||||
if (event === undefined) {
|
if (!event) {
|
||||||
emitError?.('Could not find referenced event, skipping');
|
emitError?.('Could not find referenced event, skipping');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -170,3 +171,195 @@ export function parseRundown(
|
|||||||
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
|
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
|
||||||
return parsedRundown;
|
return parsedRundown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to add an entry, mutates given assignedCustomFields in place
|
||||||
|
* @param label
|
||||||
|
* @param eventId
|
||||||
|
*/
|
||||||
|
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 type ProcessedRundownMetadata = RundownMetadata & {
|
||||||
|
entries: RundownEntries;
|
||||||
|
order: EntryId[];
|
||||||
|
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration
|
||||||
|
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
||||||
|
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
|
||||||
|
assignedCustomFields: Record<CustomFieldKey, string[]>; // Custom fields assigned to events
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a rundown metadata processor
|
||||||
|
* @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata
|
||||||
|
*/
|
||||||
|
export function makeRundownMetadata(customFields: CustomFields) {
|
||||||
|
let rundownMeta: ProcessedRundownMetadata = {
|
||||||
|
totalDelay: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalDays: 0,
|
||||||
|
firstStart: null,
|
||||||
|
lastEnd: null,
|
||||||
|
|
||||||
|
assignedCustomFields: {},
|
||||||
|
playableEventOrder: [],
|
||||||
|
timedEventOrder: [],
|
||||||
|
flatEntryOrder: [],
|
||||||
|
|
||||||
|
entries: {},
|
||||||
|
order: [],
|
||||||
|
previousEvent: null,
|
||||||
|
latestEvent: null,
|
||||||
|
previousEntry: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function process<T extends OntimeEntry>(
|
||||||
|
entry: T,
|
||||||
|
childOfBlock: EntryId | null,
|
||||||
|
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||||
|
const data = processEntry(rundownMeta, customFields, entry, childOfBlock);
|
||||||
|
rundownMeta = data.processedData;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadata(): ProcessedRundownMetadata {
|
||||||
|
return rundownMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { process, getMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a single entry and updates the rundown metadata
|
||||||
|
*/
|
||||||
|
function processEntry<T extends OntimeEntry>(
|
||||||
|
rundownMetadata: ProcessedRundownMetadata,
|
||||||
|
customFields: CustomFields,
|
||||||
|
entry: T,
|
||||||
|
childOfBlock: EntryId | null,
|
||||||
|
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||||
|
const processedData = { ...rundownMetadata };
|
||||||
|
const currentEntry = structuredClone(entry);
|
||||||
|
processedData.flatEntryOrder.push(currentEntry.id);
|
||||||
|
|
||||||
|
if (isOntimeEvent(currentEntry)) {
|
||||||
|
processedData.timedEventOrder.push(currentEntry.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.Checks that link can be established (ie, events exist and are valid)
|
||||||
|
* and populates the time data from link
|
||||||
|
* The linked event is always the previous playable event
|
||||||
|
* If no previous event exists, the link is removed
|
||||||
|
*/
|
||||||
|
if (currentEntry.linkStart) {
|
||||||
|
if (processedData.previousEvent) {
|
||||||
|
const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent);
|
||||||
|
currentEntry.timeStart = timePatch.timeStart;
|
||||||
|
currentEntry.timeEnd = timePatch.timeEnd;
|
||||||
|
currentEntry.duration = timePatch.duration;
|
||||||
|
} else {
|
||||||
|
currentEntry.linkStart = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. handle custom fields - mutates currentEntry
|
||||||
|
handleCustomField(customFields, currentEntry, processedData.assignedCustomFields);
|
||||||
|
|
||||||
|
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||||
|
currentEntry.dayOffset = processedData.totalDays;
|
||||||
|
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||||
|
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||||
|
currentEntry.parent = childOfBlock;
|
||||||
|
|
||||||
|
// update rundown metadata, it only concerns playable events
|
||||||
|
if (isPlayableEvent(currentEntry)) {
|
||||||
|
processedData.playableEventOrder.push(currentEntry.id);
|
||||||
|
|
||||||
|
// first start is always the first event
|
||||||
|
if (processedData.firstStart === null) {
|
||||||
|
processedData.firstStart = currentEntry.timeStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent);
|
||||||
|
|
||||||
|
if (currentEntry.gap === 0) {
|
||||||
|
// event starts on previous finish, we add its duration
|
||||||
|
processedData.totalDuration += currentEntry.duration;
|
||||||
|
} else if (currentEntry.gap > 0) {
|
||||||
|
// event has a gap, we add the gap and the duration
|
||||||
|
processedData.totalDuration += currentEntry.gap + currentEntry.duration;
|
||||||
|
} else if (currentEntry.gap < 0) {
|
||||||
|
// there is an overlap, we remove the overlap from the duration
|
||||||
|
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||||
|
// NOTE: we add the gap since it is a negative number
|
||||||
|
processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove eventual gaps from the accumulated delay
|
||||||
|
// we only affect positive delays (time forwards)
|
||||||
|
if (processedData.totalDelay > 0 && currentEntry.gap > 0) {
|
||||||
|
let correctedDelay = 0;
|
||||||
|
// we need to separate the delay that is accumulated from one that may exist after the gap
|
||||||
|
if (isOntimeDelay(processedData.previousEntry)) {
|
||||||
|
correctedDelay = processedData.previousEntry.duration;
|
||||||
|
processedData.totalDelay -= correctedDelay;
|
||||||
|
}
|
||||||
|
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
|
||||||
|
processedData.totalDelay += correctedDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// current event delay is the current accumulated delay
|
||||||
|
currentEntry.delay = processedData.totalDelay;
|
||||||
|
|
||||||
|
// assign data for next iteration
|
||||||
|
processedData.previousEvent = currentEntry;
|
||||||
|
|
||||||
|
// lastEntry is the event with the latest end time
|
||||||
|
if (isNewLatest(currentEntry, processedData.latestEvent)) {
|
||||||
|
processedData.latestEvent = currentEntry;
|
||||||
|
processedData.lastEnd = currentEntry.timeEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isOntimeDelay(currentEntry)) {
|
||||||
|
// !!! this must happen after handling the links
|
||||||
|
processedData.totalDelay += currentEntry.duration;
|
||||||
|
currentEntry.parent = childOfBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!childOfBlock) {
|
||||||
|
processedData.order.push(currentEntry.id);
|
||||||
|
}
|
||||||
|
processedData.entries[currentEntry.id] = currentEntry;
|
||||||
|
processedData.previousEntry = currentEntry;
|
||||||
|
|
||||||
|
return { processedData, processedEntry: currentEntry };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
CustomField,
|
||||||
|
CustomFieldKey,
|
||||||
CustomFields,
|
CustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
EventPostPayload,
|
EventPostPayload,
|
||||||
@@ -9,21 +11,21 @@ import {
|
|||||||
PatchWithId,
|
PatchWithId,
|
||||||
Rundown,
|
Rundown,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
import { customFieldLabelToKey } from 'ontime-utils';
|
||||||
|
|
||||||
import { getPreviousId } from '../../services/rundown-service/rundownUtils.js';
|
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
import { sendRefetch } from '../../adapters/websocketAux.js';
|
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||||
|
|
||||||
import { createTransaction, rundownCache, rundownMutation } from './rundown.dao.js';
|
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js';
|
||||||
import { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import { generateEvent, hasChanges } from './rundown.utils.js';
|
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* creates a new entry with given data
|
* creates a new entry with given data
|
||||||
*/
|
*/
|
||||||
export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry> {
|
export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
// we allow the user to provide an ID, but make sure it is unique
|
// we allow the user to provide an ID, but make sure it is unique
|
||||||
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
||||||
@@ -41,7 +43,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
// normalise the position of the event in the rundown order
|
// normalise the position of the event in the rundown order
|
||||||
const afterId = getPreviousId(rundown, eventData?.after, eventData?.before);
|
const afterId = getInsertAfterId(rundown, eventData?.after, eventData?.before);
|
||||||
|
|
||||||
// generate a fully formed entry from the patch
|
// generate a fully formed entry from the patch
|
||||||
const newEntry = generateEvent(rundown, eventData, afterId);
|
const newEntry = generateEvent(rundown, eventData, afterId);
|
||||||
@@ -66,7 +68,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
|||||||
* Applies a patch to an entry in the rundown
|
* Applies a patch to an entry in the rundown
|
||||||
*/
|
*/
|
||||||
export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
const currentEntry = rundown.entries[patch.id];
|
const currentEntry = rundown.entries[patch.id];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,7 +117,7 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
|||||||
* Applies a patch to several entries in the rundown
|
* Applies a patch to several entries in the rundown
|
||||||
*/
|
*/
|
||||||
export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntry>): Promise<Rundown> {
|
export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntry>): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We can do some validation globally, but mostly we will validate each entry individually
|
* We can do some validation globally, but mostly we will validate each entry individually
|
||||||
@@ -179,7 +181,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
|||||||
* Deletes a known entry from the current rundown
|
* Deletes a known entry from the current rundown
|
||||||
*/
|
*/
|
||||||
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
for (let i = 0; i < entryIds.length; i++) {
|
for (let i = 0; i < entryIds.length; i++) {
|
||||||
const entry = rundown.entries[entryIds[i]];
|
const entry = rundown.entries[entryIds[i]];
|
||||||
@@ -207,7 +209,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
|||||||
* Deletes all entries from the current rundown
|
* Deletes all entries from the current rundown
|
||||||
*/
|
*/
|
||||||
export async function deleteAllEntries(): Promise<Rundown> {
|
export async function deleteAllEntries(): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
rundownMutation.removeAll(rundown);
|
rundownMutation.removeAll(rundown);
|
||||||
|
|
||||||
@@ -231,7 +233,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
|||||||
* @throws if entryId or destinationId not found
|
* @throws if entryId or destinationId not found
|
||||||
*/
|
*/
|
||||||
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
// check that both entries exist
|
// check that both entries exist
|
||||||
const eventFrom = rundown.entries[entryId];
|
const eventFrom = rundown.entries[entryId];
|
||||||
@@ -262,7 +264,7 @@ export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'b
|
|||||||
* The applied delay is deleted
|
* The applied delay is deleted
|
||||||
*/
|
*/
|
||||||
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
// check that delay exists
|
// check that delay exists
|
||||||
const delay = rundown.entries[delayId];
|
const delay = rundown.entries[delayId];
|
||||||
@@ -292,7 +294,7 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
|||||||
* Swaps the data between two events in the rundown
|
* Swaps the data between two events in the rundown
|
||||||
*/
|
*/
|
||||||
export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundown> {
|
export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
const eventFrom = rundown.entries[fromId];
|
const eventFrom = rundown.entries[fromId];
|
||||||
const eventTo = rundown.entries[toId];
|
const eventTo = rundown.entries[toId];
|
||||||
|
|
||||||
@@ -326,7 +328,7 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
|||||||
* @throws if the entry to clone does not exist
|
* @throws if the entry to clone does not exist
|
||||||
*/
|
*/
|
||||||
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
|
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
const originalEntry = rundown.entries[entryId];
|
const originalEntry = rundown.entries[entryId];
|
||||||
|
|
||||||
if (!originalEntry) {
|
if (!originalEntry) {
|
||||||
@@ -359,7 +361,7 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
|
|||||||
* Groups a list of entries into a new block
|
* Groups a list of entries into a new block
|
||||||
*/
|
*/
|
||||||
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
rundownMutation.group(rundown, entryIds);
|
rundownMutation.group(rundown, entryIds);
|
||||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
@@ -380,7 +382,7 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
|||||||
* Deletes a block and moves all its children to the top level
|
* Deletes a block and moves all its children to the top level
|
||||||
*/
|
*/
|
||||||
export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
|
export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
|
||||||
const { rundown, commit } = createTransaction();
|
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||||
|
|
||||||
const block = rundown.entries[blockId];
|
const block = rundown.entries[blockId];
|
||||||
if (!block || !isOntimeBlock(block)) {
|
if (!block || !isOntimeBlock(block)) {
|
||||||
@@ -402,6 +404,106 @@ export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
|
|||||||
return rundownResult;
|
return rundownResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new custom field to the project
|
||||||
|
* @throws if the label is missing or invalid
|
||||||
|
*/
|
||||||
|
export async function createCustomField(customField: CustomField): Promise<CustomFields> {
|
||||||
|
const key = customFieldLabelToKey(customField.label);
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Unable to convert label to a valid key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { customFields, commit } = createTransaction({ mutableRundown: false, mutableCustomFields: true });
|
||||||
|
|
||||||
|
// check if label already exists
|
||||||
|
if (Object.hasOwn(customFields, key)) {
|
||||||
|
throw new Error('Label already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
customFieldMutation.add(customFields, key, customField);
|
||||||
|
|
||||||
|
// Adding a custom field has no immediate implications on the rundown
|
||||||
|
const { customFields: resultCustomFields } = commit(false);
|
||||||
|
|
||||||
|
// TODO: notify clients to refetch the custom fields
|
||||||
|
|
||||||
|
return resultCustomFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits an existing custom field
|
||||||
|
* In practice users can only change the label and the colour of the field
|
||||||
|
* @throws if the field does not exist
|
||||||
|
* @throws if the field type is changed
|
||||||
|
* @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({
|
||||||
|
mutableRundown: true,
|
||||||
|
mutableCustomFields: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(key in customFields)) {
|
||||||
|
throw new Error('Could not find label');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingField = customFields[key];
|
||||||
|
// if user provides a type, it must be the same from before
|
||||||
|
if (newField.type && existingField.type !== newField.type) {
|
||||||
|
throw new Error('Change of field type is not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
|
||||||
|
|
||||||
|
// if key has changed we remove the old reference
|
||||||
|
if (oldKey !== newKey && oldKey in customFieldsMetadata.assigned) {
|
||||||
|
customFieldMutation.renameUsages(rundown, customFieldsMetadata.assigned, oldKey, newKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
// TODO: notify clients to refetch the custom fields
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return resultCustomFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an existing custom field
|
||||||
|
*/
|
||||||
|
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
|
||||||
|
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
|
||||||
|
mutableRundown: true,
|
||||||
|
mutableCustomFields: true,
|
||||||
|
});
|
||||||
|
if (!(key in customFields)) {
|
||||||
|
return customFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
customFieldMutation.remove(customFields, key);
|
||||||
|
if (key in customFieldsMetadata.assigned) {
|
||||||
|
customFieldMutation.removeUsages(rundown, customFieldsMetadata.assigned, 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(() => {
|
||||||
|
// TODO: notify clients to refetch the custom fields
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return resultCustomFields;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces update in the store
|
* Forces update in the store
|
||||||
* Called when we make changes to the rundown object
|
* Called when we make changes to the rundown object
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CustomFieldLabel, EntryId, MaybeNumber } from 'ontime-types';
|
import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
export type RundownMetadata = {
|
export type RundownMetadata = {
|
||||||
totalDelay: number;
|
totalDelay: number;
|
||||||
@@ -10,11 +10,9 @@ export type RundownMetadata = {
|
|||||||
playableEventOrder: EntryId[]; // flat order of playable events
|
playableEventOrder: EntryId[]; // flat order of playable events
|
||||||
timedEventOrder: EntryId[]; // flat order of timed events
|
timedEventOrder: EntryId[]; // flat order of timed events
|
||||||
flatEntryOrder: EntryId[]; // flat order of entries
|
flatEntryOrder: EntryId[]; // flat order of entries
|
||||||
|
};
|
||||||
/**
|
|
||||||
* Keep track of which custom fields are used.
|
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
|
||||||
* This will be handy for when we delete custom fields
|
export type CustomFieldsMetadata = {
|
||||||
* since we can clear the custom fields from every event where they are used
|
assigned: AssignedMap;
|
||||||
*/
|
|
||||||
assignedCustomFields: Record<CustomFieldLabel, string[]>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ import {
|
|||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
TimeStrategy,
|
TimeStrategy,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { generateId, getCueCandidate, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
|
import {
|
||||||
|
dayInMs,
|
||||||
|
generateId,
|
||||||
|
getCueCandidate,
|
||||||
|
validateEndAction,
|
||||||
|
validateTimerType,
|
||||||
|
validateTimes,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
||||||
import { makeString } from '../../utils/parserUtils.js';
|
import { makeString } from '../../utils/parserUtils.js';
|
||||||
@@ -300,3 +307,54 @@ export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
|
|||||||
}
|
}
|
||||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for calculating if the current events should have a day offset
|
||||||
|
* @param current the current event under test
|
||||||
|
* @param previous the previous event
|
||||||
|
* @returns 0 or 1 for easy accumulation with the total days
|
||||||
|
*/
|
||||||
|
export function calculateDayOffset(
|
||||||
|
current: Pick<OntimeEvent, 'timeStart'>,
|
||||||
|
previous: Pick<OntimeEvent, 'timeStart' | 'duration'> | null,
|
||||||
|
) {
|
||||||
|
// if there is no previous there can't be a day offset
|
||||||
|
if (!previous) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the previous events duration is zero it will push the current event to next day
|
||||||
|
if (previous.duration === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the previous event crossed midnight then the current event is in the next day
|
||||||
|
if (previous.timeStart + previous.duration >= dayInMs) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the current events starts at the same time or before the previous event then it is the next day
|
||||||
|
if (current.timeStart <= previous.timeStart) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives an insertion order and returns the reference to an event ID
|
||||||
|
* after which we will insert the new event
|
||||||
|
*/
|
||||||
|
export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
|
||||||
|
if (afterId) {
|
||||||
|
return afterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beforeId) {
|
||||||
|
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
|
||||||
|
if (atIndex < 1) return null;
|
||||||
|
return rundown.flatOrder[atIndex - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { parseSettings } from '../settings.parser.js';
|
||||||
|
|
||||||
|
describe('parseSettings()', () => {
|
||||||
|
it('throws if settings object does not exist', () => {
|
||||||
|
expect(() => parseSettings({})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an a base model as long as we have the app version', () => {
|
||||||
|
const result = parseSettings({ settings: { version: '1' } as Settings });
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
version: expect.any(String),
|
||||||
|
serverPort: 4001,
|
||||||
|
editorKey: null,
|
||||||
|
operatorKey: null,
|
||||||
|
timeFormat: '24',
|
||||||
|
language: 'en',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { DatabaseModel, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse settings portion of a project file
|
||||||
|
*/
|
||||||
|
export function parseSettings(data: Partial<DatabaseModel>): Settings {
|
||||||
|
// skip if file definition is missing
|
||||||
|
// TODO: skip parsing if the version is not correct
|
||||||
|
if (!data.settings || data.settings?.version == null) {
|
||||||
|
throw new Error('ERROR: unable to parse settings, missing or incorrect version');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found settings, importing...');
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: dbModel.settings.version,
|
||||||
|
serverPort: data.settings.serverPort ?? dbModel.settings.serverPort,
|
||||||
|
editorKey: data.settings.editorKey ?? null,
|
||||||
|
operatorKey: data.settings.operatorKey ?? null,
|
||||||
|
timeFormat: data.settings.timeFormat ?? '24',
|
||||||
|
language: data.settings.language ?? 'en',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
* Google Sheets
|
* Google Sheets
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
|
||||||
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
|
|
||||||
|
|
||||||
import { deleteFile } from '../../utils/parserUtils.js';
|
|
||||||
import {
|
import {
|
||||||
revoke,
|
revoke,
|
||||||
handleClientSecret,
|
handleClientSecret,
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
upload,
|
upload,
|
||||||
getWorksheetOptions,
|
getWorksheetOptions,
|
||||||
} from '../../services/sheet-service/SheetService.js';
|
} from '../../services/sheet-service/SheetService.js';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { deleteFile } from '../../utils/fileManagement.js';
|
||||||
|
|
||||||
export async function requestConnection(
|
export async function requestConnection(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -40,11 +40,7 @@ export async function requestConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// delete uploaded file after parsing
|
// delete uploaded file after parsing
|
||||||
try {
|
await deleteFile(filePath);
|
||||||
await deleteFile(filePath);
|
|
||||||
} catch (_error) {
|
|
||||||
/** we dont handle failure here */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyAuthentication(
|
export async function verifyAuthentication(
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import multer, { FileFilterCallback } from 'multer';
|
import multer, { FileFilterCallback } from 'multer';
|
||||||
|
|
||||||
import { JSON_MIME } from '../../utils/parser.js';
|
|
||||||
import { storage } from '../../utils/upload.js';
|
import { storage } from '../../utils/upload.js';
|
||||||
|
|
||||||
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||||
if (file.mimetype.includes(JSON_MIME)) {
|
if (file.mimetype.includes('application/json')) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(null, false);
|
cb(null, false);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { DatabaseModel, URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
|
import { parseUrlPresets } from '../urlPresets.parser.js';
|
||||||
|
|
||||||
|
describe('parseUrlPresets()', () => {
|
||||||
|
it('returns an a base model if nothing is given', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const result = parseUrlPresets({}, errorEmitter);
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
expect(errorEmitter).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses data, skipping invalid results', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[];
|
||||||
|
const result = parseUrlPresets({ urlPresets }, errorEmitter);
|
||||||
|
expect(result.length).toEqual(1);
|
||||||
|
expect(result.at(0)).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
alias: 'alias',
|
||||||
|
pathAndParams: 'ss',
|
||||||
|
});
|
||||||
|
expect(errorEmitter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports a well defined urlPreset', () => {
|
||||||
|
const testData = {
|
||||||
|
rundown: [],
|
||||||
|
settings: {
|
||||||
|
version: '2.0.0',
|
||||||
|
},
|
||||||
|
urlPresets: [
|
||||||
|
{
|
||||||
|
enabled: false,
|
||||||
|
alias: 'testalias',
|
||||||
|
pathAndParams: 'testpathAndParams',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DatabaseModel;
|
||||||
|
|
||||||
|
const parsed = parseUrlPresets(testData);
|
||||||
|
expect(parsed.length).toBe(1);
|
||||||
|
|
||||||
|
// generates missing id
|
||||||
|
expect(parsed[0].alias).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { DatabaseModel, URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
|
import { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse URL preset portion of a project file
|
||||||
|
*/
|
||||||
|
export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): URLPreset[] {
|
||||||
|
if (!data.urlPresets) {
|
||||||
|
emitError?.('No data found to import');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found URL presets, importing...');
|
||||||
|
|
||||||
|
const newPresets: URLPreset[] = [];
|
||||||
|
|
||||||
|
for (const preset of data.urlPresets) {
|
||||||
|
const newPreset = {
|
||||||
|
enabled: preset.enabled ?? false,
|
||||||
|
alias: preset.alias ?? '',
|
||||||
|
pathAndParams: preset.pathAndParams ?? '',
|
||||||
|
};
|
||||||
|
newPresets.push(newPreset);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Uploaded ${newPresets.length} preset(s)`);
|
||||||
|
|
||||||
|
return newPresets;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { parseViewSettings } from '../viewSettings.parser.js';
|
||||||
|
|
||||||
|
describe('parseViewSettings()', () => {
|
||||||
|
it('returns an a base model if nothing is given', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const result = parseViewSettings({}, errorEmitter);
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
expect(errorEmitter).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { DatabaseModel, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
|
import { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse viewSettings portion of a project file
|
||||||
|
*/
|
||||||
|
export function parseViewSettings(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ViewSettings {
|
||||||
|
if (!data.viewSettings) {
|
||||||
|
emitError?.('No data found to import');
|
||||||
|
return { ...dbModel.viewSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found view settings, importing...');
|
||||||
|
|
||||||
|
return {
|
||||||
|
dangerColor: data.viewSettings.dangerColor ?? dbModel.viewSettings.dangerColor,
|
||||||
|
endMessage: data.viewSettings.endMessage ?? dbModel.viewSettings.endMessage,
|
||||||
|
freezeEnd: data.viewSettings.freezeEnd ?? dbModel.viewSettings.freezeEnd,
|
||||||
|
normalColor: data.viewSettings.normalColor ?? dbModel.viewSettings.normalColor,
|
||||||
|
overrideStyles: data.viewSettings.overrideStyles ?? dbModel.viewSettings.overrideStyles,
|
||||||
|
warningColor: data.viewSettings.warningColor ?? dbModel.viewSettings.warningColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,54 +6,6 @@ import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks_
|
|||||||
import { safeMerge } from '../DataProvider.utils.js';
|
import { safeMerge } from '../DataProvider.utils.js';
|
||||||
|
|
||||||
describe('safeMerge', () => {
|
describe('safeMerge', () => {
|
||||||
const existing = {
|
|
||||||
rundown: [],
|
|
||||||
project: {
|
|
||||||
title: 'existing title',
|
|
||||||
description: 'existing description',
|
|
||||||
publicUrl: 'existing public URL',
|
|
||||||
backstageUrl: 'existing backstageUrl',
|
|
||||||
publicInfo: 'existing backstageInfo',
|
|
||||||
backstageInfo: 'existing backstageInfo',
|
|
||||||
projectLogo: null,
|
|
||||||
custom: [
|
|
||||||
{
|
|
||||||
title: 'existing custom title',
|
|
||||||
value: 'existing custom value',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
app: 'ontime',
|
|
||||||
version: '2.0.0',
|
|
||||||
serverPort: 4001,
|
|
||||||
editorKey: null,
|
|
||||||
operatorKey: null,
|
|
||||||
timeFormat: '24',
|
|
||||||
language: 'en',
|
|
||||||
},
|
|
||||||
viewSettings: {
|
|
||||||
overrideStyles: false,
|
|
||||||
freezeEnd: false,
|
|
||||||
endMessage: 'existing endMessage',
|
|
||||||
normalColor: '#ffffffcc',
|
|
||||||
warningColor: '#FFAB33',
|
|
||||||
dangerColor: '#ED3333',
|
|
||||||
},
|
|
||||||
urlPresets: [],
|
|
||||||
customFields: {
|
|
||||||
lighting: { type: 'string', label: 'lighting', colour: 'red' },
|
|
||||||
vfx: { type: 'string', label: 'vfx', colour: 'blue' },
|
|
||||||
},
|
|
||||||
automation: {
|
|
||||||
enabledAutomations: false,
|
|
||||||
enabledOscIn: false,
|
|
||||||
oscPortIn: 8000,
|
|
||||||
triggers: [],
|
|
||||||
automations: {},
|
|
||||||
},
|
|
||||||
} as DatabaseModel;
|
|
||||||
|
|
||||||
it('returns existing data if new data is not provided', () => {
|
it('returns existing data if new data is not provided', () => {
|
||||||
const mergedData = safeMerge(demoDb, {});
|
const mergedData = safeMerge(demoDb, {});
|
||||||
expect(mergedData).toEqual(demoDb);
|
expect(mergedData).toEqual(demoDb);
|
||||||
@@ -137,42 +89,6 @@ describe('safeMerge', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should merge the urlPresets key when present', () => {
|
it('should merge the urlPresets key when present', () => {
|
||||||
const existingData = {
|
|
||||||
rundown: [],
|
|
||||||
project: {
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
publicUrl: '',
|
|
||||||
publicInfo: '',
|
|
||||||
backstageUrl: '',
|
|
||||||
backstageInfo: '',
|
|
||||||
projectLogo: null,
|
|
||||||
custom: [],
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
app: 'ontime',
|
|
||||||
version: '2.0.0',
|
|
||||||
serverPort: 4001,
|
|
||||||
operatorKey: null,
|
|
||||||
editorKey: null,
|
|
||||||
timeFormat: '24',
|
|
||||||
language: 'en',
|
|
||||||
},
|
|
||||||
viewSettings: {
|
|
||||||
overrideStyles: false,
|
|
||||||
endMessage: '',
|
|
||||||
} as ViewSettings,
|
|
||||||
urlPresets: [],
|
|
||||||
customFields: {},
|
|
||||||
automation: {
|
|
||||||
enabledAutomations: false,
|
|
||||||
enabledOscIn: false,
|
|
||||||
oscPortIn: 8000,
|
|
||||||
triggers: [],
|
|
||||||
automations: {},
|
|
||||||
},
|
|
||||||
} as DatabaseModel;
|
|
||||||
|
|
||||||
const newData = {
|
const newData = {
|
||||||
urlPresets: [
|
urlPresets: [
|
||||||
{ enabled: true, alias: 'alias1', pathAndParams: '' },
|
{ enabled: true, alias: 'alias1', pathAndParams: '' },
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Sekret',
|
Song: 'Sekret',
|
||||||
artist: 'Ronela Hajati',
|
Artist: 'Ronela Hajati',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'32d31': {
|
'32d31': {
|
||||||
@@ -82,8 +82,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Sekret',
|
Song: 'Sekret',
|
||||||
artist: 'Ronela Hajati',
|
Artist: 'Ronela Hajati',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'21cd2': {
|
'21cd2': {
|
||||||
@@ -111,8 +111,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Eat Your Salad',
|
Song: 'Eat Your Salad',
|
||||||
artist: 'Citi Zeni',
|
Artist: 'Citi Zeni',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'0b371': {
|
'0b371': {
|
||||||
@@ -140,8 +140,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Sentimentai',
|
Song: 'Sentimentai',
|
||||||
artist: 'Monika Liu',
|
Artist: 'Monika Liu',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'3cd28': {
|
'3cd28': {
|
||||||
@@ -169,8 +169,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Boys Do Cry',
|
Song: 'Boys Do Cry',
|
||||||
artist: 'Marius Bear',
|
Artist: 'Marius Bear',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
e457f: {
|
e457f: {
|
||||||
@@ -198,9 +198,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Disko',
|
Song: 'Disko',
|
||||||
artist: 'LPS',
|
Artist: 'LPS',
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
/// <----- BLOCK
|
/// <----- BLOCK
|
||||||
'01e85': {
|
'01e85': {
|
||||||
@@ -244,8 +244,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Stefania',
|
Song: 'Stefania',
|
||||||
artist: 'Kalush Orchestra',
|
Artist: 'Kalush Orchestra',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
b7737: {
|
b7737: {
|
||||||
@@ -273,8 +273,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Intention',
|
Song: 'Intention',
|
||||||
artist: 'Intelligent Music Project',
|
Artist: 'Intelligent Music Project',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
d3a80: {
|
d3a80: {
|
||||||
@@ -302,8 +302,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'De Diepte',
|
Song: 'De Diepte',
|
||||||
artist: 'S10',
|
Artist: 'S10',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'8276c': {
|
'8276c': {
|
||||||
@@ -331,8 +331,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Trenuletul',
|
Song: 'Trenuletul',
|
||||||
artist: 'Zdob si Zdub',
|
Artist: 'Zdob si Zdub',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'2340b': {
|
'2340b': {
|
||||||
@@ -360,8 +360,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Saudade Saudade',
|
Song: 'Saudade Saudade',
|
||||||
artist: 'Maro',
|
Artist: 'Maro',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/// <----- BLOCK
|
/// <----- BLOCK
|
||||||
@@ -406,8 +406,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Guilty Pleasure',
|
Song: 'Guilty Pleasure',
|
||||||
artist: 'Mia Dimsic',
|
Artist: 'Mia Dimsic',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'5e965': {
|
'5e965': {
|
||||||
@@ -435,8 +435,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'The Show',
|
Song: 'The Show',
|
||||||
artist: 'Reddi',
|
Artist: 'Reddi',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
bab4a: {
|
bab4a: {
|
||||||
@@ -464,8 +464,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Halo',
|
Song: 'Halo',
|
||||||
artist: 'LUM!X & Pia Maria',
|
Artist: 'LUM!X & Pia Maria',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
d3eb1: {
|
d3eb1: {
|
||||||
@@ -493,8 +493,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
song: 'Die Together',
|
Song: 'Die Together',
|
||||||
artist: 'Amanda Tenfjord',
|
Artist: 'Amanda Tenfjord',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -528,12 +528,12 @@ export const demoDb: DatabaseModel = {
|
|||||||
warningColor: '#FFAB33',
|
warningColor: '#FFAB33',
|
||||||
},
|
},
|
||||||
customFields: {
|
customFields: {
|
||||||
song: {
|
Song: {
|
||||||
label: 'Song',
|
label: 'Song',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
},
|
},
|
||||||
artist: {
|
Artist: {
|
||||||
label: 'Artist',
|
label: 'Artist',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DatabaseModel, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
import { DatabaseModel, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
||||||
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { copyFile } from 'fs/promises';
|
import { copyFile } from 'fs/promises';
|
||||||
@@ -8,6 +8,7 @@ import { logger } from '../../classes/Logger.js';
|
|||||||
import { publicDir } from '../../setup/index.js';
|
import { publicDir } from '../../setup/index.js';
|
||||||
import {
|
import {
|
||||||
appendToName,
|
appendToName,
|
||||||
|
deleteFile,
|
||||||
dockerSafeRename,
|
dockerSafeRename,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
ensureJsonExtension,
|
ensureJsonExtension,
|
||||||
@@ -16,14 +17,14 @@ import {
|
|||||||
removeFileExtension,
|
removeFileExtension,
|
||||||
} from '../../utils/fileManagement.js';
|
} from '../../utils/fileManagement.js';
|
||||||
import { dbModel } from '../../models/dataModel.js';
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
import { deleteFile } from '../../utils/parserUtils.js';
|
|
||||||
import { parseDatabaseModel } from '../../utils/parser.js';
|
|
||||||
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
||||||
import { demoDb } from '../../models/demoProject.js';
|
import { demoDb } from '../../models/demoProject.js';
|
||||||
import { config } from '../../setup/config.js';
|
import { config } from '../../setup/config.js';
|
||||||
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
||||||
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||||
|
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
|
||||||
|
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getLastLoadedProject,
|
getLastLoadedProject,
|
||||||
@@ -31,7 +32,6 @@ import {
|
|||||||
setLastLoadedProject,
|
setLastLoadedProject,
|
||||||
} from '../app-state-service/AppStateService.js';
|
} from '../app-state-service/AppStateService.js';
|
||||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||||
import { getFirstRundown } from '../rundown-service/rundownUtils.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyCorruptFile,
|
copyCorruptFile,
|
||||||
@@ -93,6 +93,7 @@ async function loadProject(projectData: DatabaseModel, fileName: string) {
|
|||||||
|
|
||||||
// load the first rundown in the project
|
// load the first rundown in the project
|
||||||
const firstRundown = getFirstRundown(projectData.rundowns);
|
const firstRundown = getFirstRundown(projectData.rundowns);
|
||||||
|
|
||||||
await initRundown(firstRundown, projectData.customFields);
|
await initRundown(firstRundown, projectData.customFields);
|
||||||
|
|
||||||
// persist the project selection
|
// persist the project selection
|
||||||
@@ -296,13 +297,16 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
|
|||||||
|
|
||||||
// ... but rundown and custom fields need to be checked
|
// ... but rundown and custom fields need to be checked
|
||||||
if (rundowns != null) {
|
if (rundowns != null) {
|
||||||
const result = parseRundowns(data);
|
const customFields = parseCustomFields(data);
|
||||||
|
const result = parseRundowns(data, customFields);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user may have multiple rundowns
|
* The user may have multiple rundowns
|
||||||
* We currently ignore all other rundowns
|
* We currently ignore all other rundowns
|
||||||
*/
|
*/
|
||||||
const firstRundown = getFirstRundown(result.rundowns);
|
const firstRundown = getFirstRundown(result);
|
||||||
await initRundown(firstRundown, result.customFields);
|
|
||||||
|
await initRundown(firstRundown, customFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedData = await getDataProvider().getData();
|
const updatedData = await getDataProvider().getData();
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { CustomFields, Rundown } from 'ontime-types';
|
|
||||||
|
|
||||||
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
|
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
|
||||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
|
||||||
|
|
||||||
import * as cache from './rundownCache.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces update in the store
|
|
||||||
* Called when we make changes to the rundown object
|
|
||||||
*/
|
|
||||||
function updateRuntimeOnChange() {
|
|
||||||
const { timedEventsOrder } = cache.getEventOrder();
|
|
||||||
const numEvents = timedEventsOrder.length;
|
|
||||||
const metadata = cache.getMetadata();
|
|
||||||
|
|
||||||
// schedule an update for the end of the event loop
|
|
||||||
setImmediate(() =>
|
|
||||||
updateRundownData({
|
|
||||||
numEvents,
|
|
||||||
...metadata,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotifyChangesOptions = {
|
|
||||||
timer?: boolean | string[]; // whether to notify the timer, could be a yes / no or an array of affected IDs
|
|
||||||
external?: boolean; // whether to notify external services
|
|
||||||
reload?: boolean; // major change, clients should consider refetching everything
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notify services of changes in the rundown
|
|
||||||
*/
|
|
||||||
function notifyChanges(options: NotifyChangesOptions) {
|
|
||||||
if (options.timer) {
|
|
||||||
const { playableEventsOrder } = cache.getEventOrder();
|
|
||||||
|
|
||||||
if (playableEventsOrder.length === 0) {
|
|
||||||
runtimeService.stop();
|
|
||||||
} else {
|
|
||||||
// notify timer service of changed events
|
|
||||||
// timer can be true or an array of changed IDs
|
|
||||||
const affected = Array.isArray(options.timer) ? options.timer : undefined;
|
|
||||||
runtimeService.notifyOfChangedEvents(affected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.external) {
|
|
||||||
// advice socket subscribers of change
|
|
||||||
const payload = {
|
|
||||||
target: RefetchTargets.Rundown,
|
|
||||||
changes: Array.isArray(options.timer) ? options.timer : undefined,
|
|
||||||
reload: options.reload,
|
|
||||||
revision: cache.getMetadata().revision,
|
|
||||||
};
|
|
||||||
sendRefetch(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>) {
|
|
||||||
await cache.init(rundown, customFields);
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer of change
|
|
||||||
notifyChanges({ timer: true, external: true, reload: true });
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types';
|
|
||||||
import { defaultRundown } from '../../../models/dataModel.js';
|
|
||||||
|
|
||||||
const baseEvent = {
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
skip: false,
|
|
||||||
revision: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseBlock = {
|
|
||||||
type: SupportedEntry.Block,
|
|
||||||
events: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to create a Ontime event
|
|
||||||
*/
|
|
||||||
export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
|
|
||||||
return {
|
|
||||||
...baseEvent,
|
|
||||||
...patch,
|
|
||||||
} as OntimeEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to create a delay event
|
|
||||||
*/
|
|
||||||
export function makeOntimeDelay(patch: Partial<OntimeDelay>): OntimeDelay {
|
|
||||||
return { id: 'delay', type: SupportedEntry.Delay, duration: 0, ...patch } as OntimeDelay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to create a block event
|
|
||||||
*/
|
|
||||||
export function makeOntimeBlock(patch: Partial<OntimeBlock>): OntimeBlock {
|
|
||||||
return { id: 'block', ...baseBlock, ...patch } as OntimeBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to create a rundown object
|
|
||||||
*/
|
|
||||||
export function makeRundown(patch: Partial<Rundown>): Rundown {
|
|
||||||
return {
|
|
||||||
...defaultRundown,
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to generate a rundown of OntimeEvents form partial objects
|
|
||||||
*/
|
|
||||||
export function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
|
|
||||||
return events.map(makeOntimeEvent);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { createCustomField, editCustomField, removeCustomField, customFieldChangelog } from '../rundownCache.js';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
|
||||||
return {
|
|
||||||
getDataProvider: vi.fn().mockImplementation(() => {
|
|
||||||
return {
|
|
||||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('custom fields flow', () => {
|
|
||||||
describe('createCustomField()', () => {
|
|
||||||
it('creates a field from given parameters', () => {
|
|
||||||
const expected = {
|
|
||||||
Lighting: {
|
|
||||||
label: 'Lighting',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'blue',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const customField = createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
|
|
||||||
expect(customField).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('editCustomField()', () => {
|
|
||||||
it('edits a field with a given label', () => {
|
|
||||||
createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
|
|
||||||
|
|
||||||
const expected = {
|
|
||||||
Lighting: {
|
|
||||||
label: 'Lighting',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'blue',
|
|
||||||
},
|
|
||||||
Sound: {
|
|
||||||
label: 'Sound',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'green',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
|
|
||||||
expect(customFieldChangelog).toStrictEqual({});
|
|
||||||
expect(customField).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renames a field to a new label', () => {
|
|
||||||
const created = createCustomField({ label: 'Video', type: 'string', colour: 'red' });
|
|
||||||
|
|
||||||
const expected = {
|
|
||||||
Lighting: {
|
|
||||||
label: 'Lighting',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'blue',
|
|
||||||
},
|
|
||||||
Sound: {
|
|
||||||
label: 'Sound',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'green',
|
|
||||||
},
|
|
||||||
Video: {
|
|
||||||
label: 'Video',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(created).toStrictEqual(expected);
|
|
||||||
|
|
||||||
const expectedAfter = {
|
|
||||||
Lighting: {
|
|
||||||
label: 'Lighting',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'blue',
|
|
||||||
},
|
|
||||||
Sound: {
|
|
||||||
label: 'Sound',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'green',
|
|
||||||
},
|
|
||||||
AV: {
|
|
||||||
label: 'AV',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// We need to flush all scheduled tasks for the generate function to settle
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
|
|
||||||
expect(customField).toStrictEqual(expectedAfter);
|
|
||||||
expect(customFieldChangelog).toStrictEqual({ Video: 'AV' });
|
|
||||||
editCustomField('AV', { label: 'Video' });
|
|
||||||
vi.runAllTimers();
|
|
||||||
expect(customFieldChangelog).toStrictEqual({});
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('removeCustomField()', () => {
|
|
||||||
it('deletes a field with a given label', () => {
|
|
||||||
const expected = {
|
|
||||||
Lighting: {
|
|
||||||
label: 'Lighting',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'blue',
|
|
||||||
},
|
|
||||||
Video: {
|
|
||||||
label: 'Video',
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const customField = removeCustomField('Sound');
|
|
||||||
|
|
||||||
expect(customField).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { CustomFields, SupportedEntry } from 'ontime-types';
|
|
||||||
import { addToCustomAssignment, calculateDayOffset, handleCustomField } from '../rundownCache.utils.js';
|
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
|
||||||
import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
|
||||||
|
|
||||||
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', () => {
|
|
||||||
const customFields = {
|
|
||||||
lighting: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'lighting',
|
|
||||||
},
|
|
||||||
sound: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'sound',
|
|
||||||
},
|
|
||||||
} as CustomFields;
|
|
||||||
const customFieldChangelog = {};
|
|
||||||
|
|
||||||
const event = makeOntimeEvent({
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
id: '2',
|
|
||||||
timeStart: 0,
|
|
||||||
linkStart: true,
|
|
||||||
custom: {
|
|
||||||
lighting: 'on',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const assignedCustomFields = {};
|
|
||||||
|
|
||||||
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
|
|
||||||
expect(event.custom).toStrictEqual({
|
|
||||||
lighting: 'on',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renames a field if in changelog', () => {
|
|
||||||
const customFields = {
|
|
||||||
lighting: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'lighting',
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'video',
|
|
||||||
},
|
|
||||||
} as CustomFields;
|
|
||||||
|
|
||||||
const customFieldChangelog = { sound: 'video' };
|
|
||||||
|
|
||||||
const event = makeOntimeEvent({
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
id: '2',
|
|
||||||
timeStart: 0,
|
|
||||||
linkStart: true,
|
|
||||||
custom: {
|
|
||||||
sound: 'on',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const assignedCustomFields = {};
|
|
||||||
|
|
||||||
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect(assignedCustomFields).toStrictEqual({ video: ['2'] });
|
|
||||||
expect(event.custom).toStrictEqual({
|
|
||||||
video: 'on',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('processes all fields', () => {
|
|
||||||
const customFields = {
|
|
||||||
field1: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'field1',
|
|
||||||
},
|
|
||||||
field2: {
|
|
||||||
type: 'string',
|
|
||||||
colour: 'red',
|
|
||||||
label: 'field2',
|
|
||||||
},
|
|
||||||
} as CustomFields;
|
|
||||||
|
|
||||||
const customFieldChangelog = { field1: 'newField1' };
|
|
||||||
|
|
||||||
const mutableEvent = makeOntimeEvent({
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
id: 'event1',
|
|
||||||
custom: {
|
|
||||||
field1: 'value1',
|
|
||||||
field2: 'value2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const assignedCustomFields = {};
|
|
||||||
|
|
||||||
handleCustomField(customFields, customFieldChangelog, mutableEvent, assignedCustomFields);
|
|
||||||
|
|
||||||
// Check that field1 has been renamed to newField1 and the value reassigned
|
|
||||||
expect(mutableEvent.custom['newField1']).toStrictEqual('value1');
|
|
||||||
expect(mutableEvent.custom['field1']).toBeUndefined();
|
|
||||||
|
|
||||||
// Check that field2 has been processed
|
|
||||||
expect(mutableEvent.custom['field2']).toStrictEqual('value2');
|
|
||||||
|
|
||||||
// Check that assignedCustomFields has been updated correctly
|
|
||||||
expect(assignedCustomFields).toStrictEqual({
|
|
||||||
newField1: ['event1'],
|
|
||||||
field2: ['event1'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('calculateDayOffset', () => {
|
|
||||||
it('returns 0 if there is no previous event', () => {
|
|
||||||
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 0 if the previous event duration is 0', () => {
|
|
||||||
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 0 if event starts after previous', () => {
|
|
||||||
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 1 if event starts before previous', () => {
|
|
||||||
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 1 if event starts at the same time as one before', () => {
|
|
||||||
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should account for an event that crossed midnight and there is a overlap', () => {
|
|
||||||
expect(
|
|
||||||
calculateDayOffset(
|
|
||||||
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
|
|
||||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
|
||||||
),
|
|
||||||
).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should account for an event that crossed midnight and there is a gap', () => {
|
|
||||||
expect(
|
|
||||||
calculateDayOffset(
|
|
||||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
|
||||||
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
|
|
||||||
),
|
|
||||||
).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
|
|
||||||
expect(
|
|
||||||
calculateDayOffset(
|
|
||||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
|
||||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
|
||||||
),
|
|
||||||
).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should account for an event that finishes exactly at midnight', () => {
|
|
||||||
expect(
|
|
||||||
calculateDayOffset(
|
|
||||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
|
||||||
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
|
|
||||||
),
|
|
||||||
).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
|
||||||
import { getPreviousId } from '../rundownUtils.js';
|
|
||||||
|
|
||||||
describe('getPreviousId', () => {
|
|
||||||
const rundown = makeRundown({
|
|
||||||
flatOrder: ['a', 'b', 'c', 'd'],
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns afterId if provided', () => {
|
|
||||||
expect(getPreviousId(rundown, 'b')).toBe('b');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the previous id before beforeId if provided', () => {
|
|
||||||
expect(getPreviousId(rundown, undefined, 'c')).toBe('b');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
|
||||||
expect(getPreviousId(rundown)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if beforeId is not found', () => {
|
|
||||||
expect(getPreviousId(rundown, undefined, 'z')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
import {
|
|
||||||
CustomField,
|
|
||||||
CustomFieldLabel,
|
|
||||||
CustomFields,
|
|
||||||
EntryId,
|
|
||||||
isOntimeBlock,
|
|
||||||
isOntimeEvent,
|
|
||||||
isPlayableEvent,
|
|
||||||
OntimeBlock,
|
|
||||||
OntimeEntry,
|
|
||||||
Rundown,
|
|
||||||
RundownEntries,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { generateId, insertAtIndex, customFieldLabelToKey } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
|
||||||
|
|
||||||
import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
|
||||||
import { makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js';
|
|
||||||
|
|
||||||
let currentRundownId: EntryId = '';
|
|
||||||
let currentRundown: Rundown = {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
order: [],
|
|
||||||
flatOrder: [],
|
|
||||||
entries: {},
|
|
||||||
revision: 0,
|
|
||||||
};
|
|
||||||
let projectCustomFields: CustomFields = {};
|
|
||||||
let rundownMetadata: RundownMetadata = {
|
|
||||||
totalDelay: 0,
|
|
||||||
totalDuration: 0,
|
|
||||||
totalDays: 0,
|
|
||||||
firstStart: null,
|
|
||||||
lastEnd: null,
|
|
||||||
|
|
||||||
playableEventOrder: [],
|
|
||||||
timedEventOrder: [],
|
|
||||||
flatEntryOrder: [],
|
|
||||||
|
|
||||||
assignedCustomFields: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cached rundown without triggering regeneration
|
|
||||||
*/
|
|
||||||
export const getCurrentRundown = (): Rundown => currentRundown;
|
|
||||||
export const getCustomFields = (): CustomFields => projectCustomFields;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* all mutating functions will set this value if there is a need for re-generation
|
|
||||||
* but will only be cleared by the generate function
|
|
||||||
*/
|
|
||||||
let isStale = true;
|
|
||||||
|
|
||||||
/** Allows safely setting the stale state without accidentally clearing it */
|
|
||||||
function setIsStale() {
|
|
||||||
isStale = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object that contains reference of renamed custom fields
|
|
||||||
* Used to rename the custom fields in the events
|
|
||||||
* @private exported only to simplify testing
|
|
||||||
* @example
|
|
||||||
* {
|
|
||||||
* oldLabel: newLabel
|
|
||||||
* lighting: lx
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export let customFieldChangelog: Record<string, string> = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives a rundown which will be processed and used as the new current rundown
|
|
||||||
*/
|
|
||||||
export async function init(initialRundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
|
||||||
// we clone this objects since we use mutating logic in the cache
|
|
||||||
currentRundown = structuredClone(initialRundown);
|
|
||||||
currentRundownId = initialRundown.id;
|
|
||||||
projectCustomFields = structuredClone(customFields);
|
|
||||||
|
|
||||||
updateCache();
|
|
||||||
|
|
||||||
currentRundownId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility generate cache
|
|
||||||
* @private should not be called outside of `rundownCache.ts`, exported for testing
|
|
||||||
*/
|
|
||||||
export function generate(
|
|
||||||
initialRundown: Readonly<Rundown>,
|
|
||||||
customFields: Readonly<CustomFields>,
|
|
||||||
): ProcessedRundownMetadata {
|
|
||||||
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
|
|
||||||
|
|
||||||
for (let i = 0; i < initialRundown.order.length; i++) {
|
|
||||||
// we assign a reference to the current entry, this will be mutated in place
|
|
||||||
const currentEntryId = initialRundown.order[i];
|
|
||||||
const currentEntry = initialRundown.entries[currentEntryId];
|
|
||||||
if (!currentEntry) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { processedEntry } = process(currentEntry, null);
|
|
||||||
|
|
||||||
// if the event is a block, we process the nested entries
|
|
||||||
// the code here is a copy of the processing of top level events
|
|
||||||
if (isOntimeBlock(processedEntry)) {
|
|
||||||
let totalBlockDuration = 0;
|
|
||||||
let blockStartTime = null;
|
|
||||||
let blockEndTime = null;
|
|
||||||
let isFirstLinked = false;
|
|
||||||
const blockEvents: EntryId[] = [];
|
|
||||||
|
|
||||||
// check if the block contains events
|
|
||||||
for (let i = 0; i < processedEntry.events.length; i++) {
|
|
||||||
const nestedEntryId = processedEntry.events[i];
|
|
||||||
const nestedEntry = initialRundown.entries[nestedEntryId];
|
|
||||||
|
|
||||||
if (!nestedEntry) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockEvents.push(nestedEntry.id);
|
|
||||||
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
|
|
||||||
nestedEntry,
|
|
||||||
processedEntry.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// we dont extract metadata of skipped events,
|
|
||||||
// if this is not a playable event there is nothing else to do
|
|
||||||
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// first start is always the first event
|
|
||||||
if (blockStartTime === null) {
|
|
||||||
blockStartTime = processedNestedEntry.timeStart;
|
|
||||||
isFirstLinked = Boolean(processedNestedEntry.linkStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastEntry is the event with the latest end time
|
|
||||||
blockEndTime = processedNestedData.lastEnd;
|
|
||||||
totalBlockDuration += processedNestedEntry.duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update block metadata
|
|
||||||
processedEntry.duration = totalBlockDuration;
|
|
||||||
processedEntry.startTime = blockStartTime;
|
|
||||||
processedEntry.endTime = blockEndTime;
|
|
||||||
processedEntry.isFirstLinked = isFirstLinked;
|
|
||||||
processedEntry.events = blockEvents;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getMetadata();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the generate function in the currently loaded rundown and updates caches
|
|
||||||
*/
|
|
||||||
export function updateCache() {
|
|
||||||
// The stale state can only be cleared inside updateCache()
|
|
||||||
function clearIsStale() {
|
|
||||||
isStale = false;
|
|
||||||
}
|
|
||||||
const processedData = generate(currentRundown, 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, ...metadata } = processedData;
|
|
||||||
currentRundown.entries = metadata.entries;
|
|
||||||
currentRundown.order = metadata.order;
|
|
||||||
currentRundown.flatOrder = metadata.flatEntryOrder;
|
|
||||||
rundownMetadata = metadata;
|
|
||||||
clearIsStale();
|
|
||||||
customFieldChangelog = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a given ID is exists in the current rundown
|
|
||||||
*/
|
|
||||||
export function hasId(id: EntryId): boolean {
|
|
||||||
return Object.hasOwn(currentRundown.entries, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns an ID guaranteed to be unique */
|
|
||||||
export function getUniqueId(): string {
|
|
||||||
if (isStale) {
|
|
||||||
updateCache();
|
|
||||||
}
|
|
||||||
let id = '';
|
|
||||||
do {
|
|
||||||
id = generateId();
|
|
||||||
} while (hasId(id));
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns index of an entry with a given id */
|
|
||||||
export function getIndexOf(entryId: EntryId) {
|
|
||||||
if (isStale) {
|
|
||||||
updateCache();
|
|
||||||
}
|
|
||||||
return currentRundown.order.indexOf(entryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns id of an entry at a given index */
|
|
||||||
export function getIdOf(index: number) {
|
|
||||||
if (isStale) {
|
|
||||||
updateCache();
|
|
||||||
}
|
|
||||||
return currentRundown.order.at(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RundownCache = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
order: EntryId[];
|
|
||||||
entries: RundownEntries;
|
|
||||||
revision: number;
|
|
||||||
totalDelay: number;
|
|
||||||
totalDuration: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the full rundown cache.
|
|
||||||
* Will triggering regeneration if data is stale.
|
|
||||||
*/
|
|
||||||
export function get(): Readonly<RundownCache> {
|
|
||||||
if (isStale) {
|
|
||||||
updateCache();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: currentRundown.id,
|
|
||||||
title: currentRundown.title,
|
|
||||||
entries: currentRundown.entries,
|
|
||||||
order: currentRundown.order,
|
|
||||||
revision: currentRundown.revision,
|
|
||||||
totalDelay: rundownMetadata.totalDelay,
|
|
||||||
totalDuration: rundownMetadata.totalDuration,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns calculated metadata from rundown
|
|
||||||
* Will triggering regeneration if data is stale.
|
|
||||||
*/
|
|
||||||
export function getMetadata(): Readonly<RundownMetadata & { revision: number }> {
|
|
||||||
if (isStale) {
|
|
||||||
updateCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rundownMetadata,
|
|
||||||
revision: currentRundown.revision,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RundownOrder = {
|
|
||||||
order: EntryId[];
|
|
||||||
flatOrder: EntryId[];
|
|
||||||
timedEventsOrder: EntryId[];
|
|
||||||
playableEventsOrder: EntryId[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes the order of events
|
|
||||||
*/
|
|
||||||
export function getEventOrder(): Readonly<RundownOrder> {
|
|
||||||
if (isStale) {
|
|
||||||
updateCache();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
order: currentRundown.order,
|
|
||||||
flatOrder: currentRundown.flatOrder,
|
|
||||||
timedEventsOrder: rundownMetadata.timedEventOrder,
|
|
||||||
playableEventsOrder: rundownMetadata.playableEventOrder,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommonParams = { rundown: Rundown };
|
|
||||||
type MutationParams<T> = T & CommonParams;
|
|
||||||
type MutatingReturn = {
|
|
||||||
newRundown: Rundown;
|
|
||||||
newEvent?: OntimeEntry;
|
|
||||||
changeList?: EntryId[];
|
|
||||||
didMutate: boolean;
|
|
||||||
};
|
|
||||||
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorators injects data into mutation
|
|
||||||
* ensures order of operations when performing mutations
|
|
||||||
*/
|
|
||||||
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
|
|
||||||
function scopedMutation(params: T) {
|
|
||||||
// we work on a copy of the rundown
|
|
||||||
const rundownCopy = structuredClone(currentRundown);
|
|
||||||
const { newEvent, newRundown, changeList, didMutate } = mutation({ ...params, rundown: rundownCopy });
|
|
||||||
|
|
||||||
// early return without calling side effects
|
|
||||||
if (!didMutate) {
|
|
||||||
return { newEvent, newRundown, changeList, didMutate };
|
|
||||||
}
|
|
||||||
|
|
||||||
newRundown.revision += 1;
|
|
||||||
currentRundown = newRundown;
|
|
||||||
|
|
||||||
// schedule a non priority cache update
|
|
||||||
setImmediate(() => {
|
|
||||||
get();
|
|
||||||
});
|
|
||||||
|
|
||||||
// defer writing to the database
|
|
||||||
setImmediate(async () => {
|
|
||||||
await getDataProvider().setRundown(currentRundownId, currentRundown);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { newEvent, newRundown, didMutate };
|
|
||||||
}
|
|
||||||
|
|
||||||
return scopedMutation;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddArgs = MutationParams<{ afterId?: string; parent: EntryId | null; entry: OntimeEntry }>;
|
|
||||||
/**
|
|
||||||
* Add entry to rundown, handles the following cases:
|
|
||||||
* - 1. add entry in block, after a given entry
|
|
||||||
* - 2. add entry in block, at the beginning
|
|
||||||
* - 3. add entry to the rundown, after a given entry
|
|
||||||
* - 4. add entry to the rundown, at the beginning
|
|
||||||
*/
|
|
||||||
export function add({ rundown, afterId, parent, entry }: AddArgs): Required<MutatingReturn> {
|
|
||||||
if (parent) {
|
|
||||||
const parentBlock = rundown.entries[parent] as OntimeBlock;
|
|
||||||
if (afterId) {
|
|
||||||
const atEventsIndex = parentBlock.events.indexOf(afterId) + 1;
|
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
|
||||||
parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events);
|
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
|
||||||
} else {
|
|
||||||
parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events);
|
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(parent) + 1;
|
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (afterId) {
|
|
||||||
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
|
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
|
||||||
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
|
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
|
||||||
} else {
|
|
||||||
rundown.order = insertAtIndex(0, entry.id, rundown.order);
|
|
||||||
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// either way, we insert the entry into the rundown
|
|
||||||
rundown.entries[entry.id] = entry;
|
|
||||||
setIsStale();
|
|
||||||
return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility for invalidating service cache if a custom field is used
|
|
||||||
*/
|
|
||||||
function invalidateIfUsed(label: CustomFieldLabel) {
|
|
||||||
// if the field was in use, we mark the cache as stale
|
|
||||||
if (label in rundownMetadata.assignedCustomFields) {
|
|
||||||
setIsStale();
|
|
||||||
}
|
|
||||||
// ... and schedule a cache update
|
|
||||||
// schedule a non priority cache update
|
|
||||||
setImmediate(async () => {
|
|
||||||
updateCache();
|
|
||||||
await getDataProvider().setRundown(currentRundownId, currentRundown);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility for scheduling a non priority custom field persist
|
|
||||||
*/
|
|
||||||
function scheduleCustomFieldPersist() {
|
|
||||||
setImmediate(async () => {
|
|
||||||
await getDataProvider().setCustomFields(projectCustomFields);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitises and creates a custom field in the database
|
|
||||||
*/
|
|
||||||
export function createCustomField(field: CustomField): CustomFields {
|
|
||||||
const { label, type, colour } = field;
|
|
||||||
const key = customFieldLabelToKey(label);
|
|
||||||
|
|
||||||
if (key === null) {
|
|
||||||
throw new Error('Unable to convert label to a valid key');
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if label already exists
|
|
||||||
const alreadyExists = Object.hasOwn(projectCustomFields, key);
|
|
||||||
|
|
||||||
if (alreadyExists) {
|
|
||||||
throw new Error('Label already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
// update object and persist
|
|
||||||
projectCustomFields[key] = { label, type, colour };
|
|
||||||
|
|
||||||
scheduleCustomFieldPersist();
|
|
||||||
|
|
||||||
return projectCustomFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits an existing custom field in the database
|
|
||||||
*/
|
|
||||||
export function editCustomField(key: string, newField: Partial<CustomField>): CustomFields {
|
|
||||||
if (!(key in projectCustomFields)) {
|
|
||||||
throw new Error('Could not find label');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingField = projectCustomFields[key];
|
|
||||||
if (newField.type !== undefined && existingField.type !== newField.type) {
|
|
||||||
throw new Error('Change of field type is not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newField.label === undefined) {
|
|
||||||
throw new Error('Missing label');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newKey = customFieldLabelToKey(newField.label);
|
|
||||||
if (newKey === null) {
|
|
||||||
throw new Error('Unable to convert label to a valid key');
|
|
||||||
}
|
|
||||||
projectCustomFields[newKey] = { ...existingField, ...newField };
|
|
||||||
|
|
||||||
if (key !== newKey) {
|
|
||||||
delete projectCustomFields[key];
|
|
||||||
customFieldChangelog[key] = newKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleCustomFieldPersist();
|
|
||||||
invalidateIfUsed(key);
|
|
||||||
|
|
||||||
return projectCustomFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a custom field from the database
|
|
||||||
*/
|
|
||||||
export function removeCustomField(label: string): CustomFields {
|
|
||||||
if (label in projectCustomFields) {
|
|
||||||
delete projectCustomFields[label];
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleCustomFieldPersist();
|
|
||||||
invalidateIfUsed(label);
|
|
||||||
|
|
||||||
return projectCustomFields;
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import {
|
|
||||||
OntimeEvent,
|
|
||||||
CustomFieldLabel,
|
|
||||||
CustomFields,
|
|
||||||
OntimeEntry,
|
|
||||||
EntryId,
|
|
||||||
isOntimeEvent,
|
|
||||||
isPlayableEvent,
|
|
||||||
isOntimeDelay,
|
|
||||||
PlayableEvent,
|
|
||||||
RundownEntries,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
|
||||||
|
|
||||||
import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to add an entry, mutates given assignedCustomFields in place
|
|
||||||
* @param label
|
|
||||||
* @param eventId
|
|
||||||
*/
|
|
||||||
export function addToCustomAssignment(
|
|
||||||
label: CustomFieldLabel,
|
|
||||||
eventId: string,
|
|
||||||
assignedCustomFields: Record<string, string[]>,
|
|
||||||
) {
|
|
||||||
if (!Array.isArray(assignedCustomFields[label])) {
|
|
||||||
assignedCustomFields[label] = [];
|
|
||||||
}
|
|
||||||
assignedCustomFields[label].push(eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitises custom fields and updates values if necessary
|
|
||||||
* Mutates in place mutableEvent and assignedCustomFields
|
|
||||||
*/
|
|
||||||
export function handleCustomField(
|
|
||||||
customFields: CustomFields,
|
|
||||||
customFieldChangelog: Record<string, string>,
|
|
||||||
mutableEvent: OntimeEvent,
|
|
||||||
assignedCustomFields: Record<string, string[]>,
|
|
||||||
) {
|
|
||||||
for (const field in mutableEvent.custom) {
|
|
||||||
// rename the property if it is in the changelog
|
|
||||||
if (field in customFieldChangelog) {
|
|
||||||
const oldData = mutableEvent.custom[field];
|
|
||||||
const newLabel = customFieldChangelog[field];
|
|
||||||
|
|
||||||
mutableEvent.custom[newLabel] = oldData;
|
|
||||||
delete mutableEvent.custom[field];
|
|
||||||
addToCustomAssignment(newLabel, mutableEvent.id, assignedCustomFields);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field in customFields) {
|
|
||||||
// add field to assignment map
|
|
||||||
addToCustomAssignment(field, mutableEvent.id, assignedCustomFields);
|
|
||||||
} else {
|
|
||||||
// delete data if it is not declared in project level custom fields
|
|
||||||
delete mutableEvent.custom[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility for calculating if the current events should have a day offset
|
|
||||||
* @param current the current event under test
|
|
||||||
* @param previous the previous event
|
|
||||||
* @returns 0 or 1 for easy accumulation with the total days
|
|
||||||
*/
|
|
||||||
export function calculateDayOffset(
|
|
||||||
current: Pick<OntimeEvent, 'timeStart'>,
|
|
||||||
previous: Pick<OntimeEvent, 'timeStart' | 'duration'> | null,
|
|
||||||
) {
|
|
||||||
// if there is no previous there can't be a day offset
|
|
||||||
if (!previous) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the previous events duration is zero it will push the current event to next day
|
|
||||||
if (previous.duration === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the previous event crossed midnight then the current event is in the next day
|
|
||||||
if (previous.timeStart + previous.duration >= dayInMs) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the current events starts at the same time or before the previous event then it is the next day
|
|
||||||
if (current.timeStart <= previous.timeStart) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProcessedRundownMetadata = RundownMetadata & {
|
|
||||||
entries: RundownEntries;
|
|
||||||
order: EntryId[];
|
|
||||||
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration
|
|
||||||
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
|
||||||
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create a rundown metadata processor
|
|
||||||
* @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata
|
|
||||||
*/
|
|
||||||
export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record<string, string>) {
|
|
||||||
let rundownMeta: ProcessedRundownMetadata = {
|
|
||||||
totalDelay: 0,
|
|
||||||
totalDuration: 0,
|
|
||||||
totalDays: 0,
|
|
||||||
firstStart: null,
|
|
||||||
lastEnd: null,
|
|
||||||
|
|
||||||
assignedCustomFields: {},
|
|
||||||
playableEventOrder: [],
|
|
||||||
timedEventOrder: [],
|
|
||||||
flatEntryOrder: [],
|
|
||||||
|
|
||||||
entries: {},
|
|
||||||
order: [],
|
|
||||||
previousEvent: null,
|
|
||||||
latestEvent: null,
|
|
||||||
previousEntry: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function process<T extends OntimeEntry>(
|
|
||||||
entry: T,
|
|
||||||
childOfBlock: EntryId | null,
|
|
||||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
|
||||||
const data = processEntry(rundownMeta, customFields, customFieldChangelog, entry, childOfBlock);
|
|
||||||
rundownMeta = data.processedData;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMetadata(): ProcessedRundownMetadata {
|
|
||||||
return rundownMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { process, getMetadata };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes a single entry and updates the rundown metadata
|
|
||||||
*/
|
|
||||||
function processEntry<T extends OntimeEntry>(
|
|
||||||
rundownMetadata: ProcessedRundownMetadata,
|
|
||||||
customFields: CustomFields,
|
|
||||||
customFieldChangelog: Record<string, string>,
|
|
||||||
entry: T,
|
|
||||||
childOfBlock: EntryId | null,
|
|
||||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
|
||||||
const processedData = { ...rundownMetadata };
|
|
||||||
const currentEntry = structuredClone(entry);
|
|
||||||
processedData.flatEntryOrder.push(currentEntry.id);
|
|
||||||
|
|
||||||
if (isOntimeEvent(currentEntry)) {
|
|
||||||
processedData.timedEventOrder.push(currentEntry.id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1.Checks that link can be established (ie, events exist and are valid)
|
|
||||||
* and populates the time data from link
|
|
||||||
* The linked event is always the previous playable event
|
|
||||||
* If no previous event exists, the link is removed
|
|
||||||
*/
|
|
||||||
if (currentEntry.linkStart) {
|
|
||||||
if (processedData.previousEvent) {
|
|
||||||
const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent);
|
|
||||||
currentEntry.timeStart = timePatch.timeStart;
|
|
||||||
currentEntry.timeEnd = timePatch.timeEnd;
|
|
||||||
currentEntry.duration = timePatch.duration;
|
|
||||||
} else {
|
|
||||||
currentEntry.linkStart = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. handle custom fields - mutates currentEntry
|
|
||||||
handleCustomField(customFields, customFieldChangelog, currentEntry, processedData.assignedCustomFields);
|
|
||||||
|
|
||||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
|
||||||
currentEntry.dayOffset = processedData.totalDays;
|
|
||||||
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
|
||||||
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
|
||||||
currentEntry.parent = childOfBlock;
|
|
||||||
|
|
||||||
// update rundown metadata, it only concerns playable events
|
|
||||||
if (isPlayableEvent(currentEntry)) {
|
|
||||||
processedData.playableEventOrder.push(currentEntry.id);
|
|
||||||
|
|
||||||
// first start is always the first event
|
|
||||||
if (processedData.firstStart === null) {
|
|
||||||
processedData.firstStart = currentEntry.timeStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent);
|
|
||||||
|
|
||||||
if (currentEntry.gap === 0) {
|
|
||||||
// event starts on previous finish, we add its duration
|
|
||||||
processedData.totalDuration += currentEntry.duration;
|
|
||||||
} else if (currentEntry.gap > 0) {
|
|
||||||
// event has a gap, we add the gap and the duration
|
|
||||||
processedData.totalDuration += currentEntry.gap + currentEntry.duration;
|
|
||||||
} else if (currentEntry.gap < 0) {
|
|
||||||
// there is an overlap, we remove the overlap from the duration
|
|
||||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
|
||||||
// NOTE: we add the gap since it is a negative number
|
|
||||||
processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove eventual gaps from the accumulated delay
|
|
||||||
// we only affect positive delays (time forwards)
|
|
||||||
if (processedData.totalDelay > 0 && currentEntry.gap > 0) {
|
|
||||||
let correctedDelay = 0;
|
|
||||||
// we need to separate the delay that is accumulated from one that may exist after the gap
|
|
||||||
if (isOntimeDelay(processedData.previousEntry)) {
|
|
||||||
correctedDelay = processedData.previousEntry.duration;
|
|
||||||
processedData.totalDelay -= correctedDelay;
|
|
||||||
}
|
|
||||||
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
|
|
||||||
processedData.totalDelay += correctedDelay;
|
|
||||||
}
|
|
||||||
|
|
||||||
// current event delay is the current accumulated delay
|
|
||||||
currentEntry.delay = processedData.totalDelay;
|
|
||||||
|
|
||||||
// assign data for next iteration
|
|
||||||
processedData.previousEvent = currentEntry;
|
|
||||||
|
|
||||||
// lastEntry is the event with the latest end time
|
|
||||||
if (isNewLatest(currentEntry, processedData.latestEvent)) {
|
|
||||||
processedData.latestEvent = currentEntry;
|
|
||||||
processedData.lastEnd = currentEntry.timeEnd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isOntimeDelay(currentEntry)) {
|
|
||||||
// !!! this must happen after handling the links
|
|
||||||
processedData.totalDelay += currentEntry.duration;
|
|
||||||
currentEntry.parent = childOfBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!childOfBlock) {
|
|
||||||
processedData.order.push(currentEntry.id);
|
|
||||||
}
|
|
||||||
processedData.entries[currentEntry.id] = currentEntry;
|
|
||||||
processedData.previousEntry = currentEntry;
|
|
||||||
|
|
||||||
return { processedData, processedEntry: currentEntry };
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import {
|
|
||||||
OntimeEvent,
|
|
||||||
Rundown,
|
|
||||||
OntimeEntry,
|
|
||||||
PlayableEvent,
|
|
||||||
EntryId,
|
|
||||||
RundownEntries,
|
|
||||||
ProjectRundowns,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
|
||||||
|
|
||||||
import * as cache from './rundownCache.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns the the project rundown and the order arrays
|
|
||||||
*/
|
|
||||||
export function getRundownData() {
|
|
||||||
return {
|
|
||||||
rundown: getCurrentRundown(),
|
|
||||||
rundownOrder: cache.getEventOrder(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns all events of type OntimeEvent
|
|
||||||
*/
|
|
||||||
export function getTimedEvents(): OntimeEvent[] {
|
|
||||||
const { entries } = cache.get();
|
|
||||||
const { timedEventsOrder } = cache.getEventOrder();
|
|
||||||
return makeFlatRundownFromOrder(timedEventsOrder, entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility flattens a normalised rundown
|
|
||||||
*/
|
|
||||||
function makeFlatRundownFromOrder<T>(order: EntryId[], events: RundownEntries): T[] {
|
|
||||||
return order.map((id) => events[id] as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns an event given its index after filtering for OntimeEvents
|
|
||||||
*/
|
|
||||||
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
|
|
||||||
const { timedEventsOrder } = cache.getEventOrder();
|
|
||||||
const eventId = timedEventsOrder[eventIndex];
|
|
||||||
|
|
||||||
if (!eventId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const { entries } = getCurrentRundown();
|
|
||||||
return entries[eventId] as OntimeEvent | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns first event that matches a given ID
|
|
||||||
*/
|
|
||||||
export function getEntryWithId(entryId: EntryId): OntimeEntry | undefined {
|
|
||||||
const { entries } = getCurrentRundown();
|
|
||||||
return entries[entryId];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility returns the first playable event in rundown
|
|
||||||
*/
|
|
||||||
export function getFirstPlayable(playableOrder: EntryId[]): PlayableEvent | undefined {
|
|
||||||
const firstEventId = playableOrder.at(0);
|
|
||||||
if (!firstEventId) return;
|
|
||||||
return getEntryWithId(firstEventId) as PlayableEvent | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns first event that matches a given cue
|
|
||||||
*/
|
|
||||||
export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined {
|
|
||||||
const { playableEventsOrder } = cache.getEventOrder();
|
|
||||||
|
|
||||||
const lowerCaseCue = targetCue.toLowerCase();
|
|
||||||
|
|
||||||
for (let i = currentEventIndex; i < playableEventsOrder.length; i++) {
|
|
||||||
const eventId = playableEventsOrder[i];
|
|
||||||
const event = getEntryWithId(eventId) as PlayableEvent | undefined;
|
|
||||||
if (event?.cue.toLowerCase() === lowerCaseCue) {
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* finds the previous event
|
|
||||||
*/
|
|
||||||
export function findPrevious(currentEventId?: string): OntimeEvent | undefined {
|
|
||||||
const { playableEventsOrder } = cache.getEventOrder();
|
|
||||||
|
|
||||||
if (!playableEventsOrder.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there is no event running, go to first
|
|
||||||
if (!currentEventId) {
|
|
||||||
return getFirstPlayable(playableEventsOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
|
|
||||||
const newIndex = Math.max(currentIndex - 1, 0);
|
|
||||||
const previousEventId = playableEventsOrder.at(newIndex);
|
|
||||||
|
|
||||||
if (!previousEventId) {
|
|
||||||
return getFirstPlayable(playableEventsOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getEntryWithId(previousEventId) as PlayableEvent | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* finds the next event
|
|
||||||
*/
|
|
||||||
export function findNext(currentEventId?: string): PlayableEvent | undefined {
|
|
||||||
const { playableEventOrder } = cache.getMetadata();
|
|
||||||
|
|
||||||
if (!playableEventOrder.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there is no event running, go to first
|
|
||||||
if (!currentEventId) {
|
|
||||||
return getFirstPlayable(playableEventOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndex = playableEventOrder.findIndex((eventId) => eventId === currentEventId);
|
|
||||||
const newIndex = Math.min(currentIndex + 1, playableEventOrder.length - 1);
|
|
||||||
const nextEventId = playableEventOrder.at(newIndex);
|
|
||||||
|
|
||||||
if (!nextEventId) {
|
|
||||||
return getFirstPlayable(playableEventOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getEntryWithId(nextEventId) as PlayableEvent | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): OntimeEvent[] {
|
|
||||||
return timedEventOrder.map((id) => rundown.entries[id] as OntimeEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the first rundown in the project
|
|
||||||
* We know that the project has at least one rundown
|
|
||||||
*/
|
|
||||||
export function getFirstRundown(rundowns: ProjectRundowns): Rundown {
|
|
||||||
const firstKey = Object.keys(rundowns)[0];
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
|
||||||
DEV: {
|
|
||||||
if (!firstKey) {
|
|
||||||
throw new Error('rundownUtils.getFirstRundown() No rundowns found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rundowns[firstKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a rundown given its ID
|
|
||||||
*/
|
|
||||||
export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string): Rundown {
|
|
||||||
if (!rundowns[rundownId]) {
|
|
||||||
throw new Error(`Rundown with ID ${rundownId} not found`);
|
|
||||||
}
|
|
||||||
return rundowns[rundownId];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives an insertion order and returns the reference to an event ID
|
|
||||||
* after which we will insert the new event
|
|
||||||
*/
|
|
||||||
export function getPreviousId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
|
|
||||||
if (afterId) {
|
|
||||||
return afterId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (beforeId) {
|
|
||||||
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
|
|
||||||
if (atIndex < 1) return null;
|
|
||||||
return rundown.flatOrder[atIndex - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -23,23 +23,22 @@ import { eventStore } from '../../stores/EventStore.js';
|
|||||||
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
||||||
import { timerConfig } from '../../setup/config.js';
|
import { timerConfig } from '../../setup/config.js';
|
||||||
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
|
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
|
||||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
||||||
|
|
||||||
import { EventTimer } from '../EventTimer.js';
|
import { EventTimer } from '../EventTimer.js';
|
||||||
import { RestorePoint, restoreService } from '../RestoreService.js';
|
import { RestorePoint, restoreService } from '../RestoreService.js';
|
||||||
import {
|
|
||||||
findNext,
|
|
||||||
findPrevious,
|
|
||||||
getEventAtIndex,
|
|
||||||
getNextEventWithCue,
|
|
||||||
getEntryWithId,
|
|
||||||
getTimedEvents,
|
|
||||||
getRundownData,
|
|
||||||
} from '../rundown-service/rundownUtils.js';
|
|
||||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||||
import { getEventOrder } from '../rundown-service/rundownCache.js';
|
|
||||||
|
|
||||||
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
|
import {
|
||||||
|
filterTimedEvents,
|
||||||
|
findNextPlayableId,
|
||||||
|
findNextPlayableWithCue,
|
||||||
|
findPreviousPlayableId,
|
||||||
|
getEventAtIndex,
|
||||||
|
getForceUpdate,
|
||||||
|
getShouldClockUpdate,
|
||||||
|
getShouldTimerUpdate,
|
||||||
|
} from './rundownService.utils.js';
|
||||||
|
|
||||||
type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow' | 'publicEventNow' | 'publicEventNext'>;
|
type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow' | 'publicEventNow' | 'publicEventNext'>;
|
||||||
|
|
||||||
@@ -198,7 +197,10 @@ class RuntimeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isNewNext() {
|
private isNewNext() {
|
||||||
const timedEvents = getTimedEvents();
|
const rundown = getCurrentRundown();
|
||||||
|
const { timedEventOrder } = getRundownMetadata();
|
||||||
|
const timedEvents = filterTimedEvents(rundown, timedEventOrder);
|
||||||
|
|
||||||
const state = runtimeState.getState();
|
const state = runtimeState.getState();
|
||||||
const now = state.eventNow?.id;
|
const now = state.eventNow?.id;
|
||||||
const next = state.eventNext?.id;
|
const next = state.eventNext?.id;
|
||||||
@@ -271,8 +273,8 @@ class RuntimeService {
|
|||||||
runtimeState.updateLoaded(eventNow);
|
runtimeState.updateLoaded(eventNow);
|
||||||
} else {
|
} else {
|
||||||
const rundown = getCurrentRundown();
|
const rundown = getCurrentRundown();
|
||||||
const { timedEventsOrder } = getEventOrder();
|
const { timedEventOrder } = getRundownMetadata();
|
||||||
runtimeState.updateAll(rundown, timedEventsOrder);
|
runtimeState.updateAll(rundown, timedEventOrder);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -281,7 +283,9 @@ class RuntimeService {
|
|||||||
// Maybe the event will become the next
|
// Maybe the event will become the next
|
||||||
isNext = this.isNewNext();
|
isNext = this.isNewNext();
|
||||||
if (isNext) {
|
if (isNext) {
|
||||||
const timedEvents = getTimedEvents();
|
const rundown = getCurrentRundown();
|
||||||
|
const { timedEventOrder } = getRundownMetadata();
|
||||||
|
const timedEvents = filterTimedEvents(rundown, timedEventOrder);
|
||||||
runtimeState.loadNext(timedEvents);
|
runtimeState.loadNext(timedEvents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,8 +303,10 @@ class RuntimeService {
|
|||||||
}
|
}
|
||||||
const previousState = runtimeState.getState();
|
const previousState = runtimeState.getState();
|
||||||
|
|
||||||
const { rundown, rundownOrder } = getRundownData();
|
// we can ignore events which are not playable
|
||||||
const success = runtimeState.load(event, rundown, rundownOrder.timedEventsOrder, initialData);
|
const rundown = getCurrentRundown();
|
||||||
|
const rundownMetadata = getRundownMetadata();
|
||||||
|
const success = runtimeState.load(event, rundown, rundownMetadata.playableEventOrder, initialData);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||||
@@ -342,14 +348,20 @@ class RuntimeService {
|
|||||||
*/
|
*/
|
||||||
@broadcastResult
|
@broadcastResult
|
||||||
public startByIndex(eventIndex: number): boolean {
|
public startByIndex(eventIndex: number): boolean {
|
||||||
const event = getEventAtIndex(eventIndex);
|
const rundown = getCurrentRundown();
|
||||||
|
const { timedEventOrder } = getRundownMetadata();
|
||||||
|
|
||||||
|
const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = this.loadEvent(event);
|
const loaded = this.loadEvent(event);
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.handleStart();
|
return this.handleStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,10 +372,21 @@ class RuntimeService {
|
|||||||
*/
|
*/
|
||||||
@broadcastResult
|
@broadcastResult
|
||||||
public startByCue(cue: string): boolean {
|
public startByCue(cue: string): boolean {
|
||||||
const event = getNextEventWithCue(cue); //TODO: add index
|
const state = runtimeState.getState();
|
||||||
|
const rundown = getCurrentRundown();
|
||||||
|
const { playableEventOrder } = getRundownMetadata();
|
||||||
|
|
||||||
|
const event = findNextPlayableWithCue(
|
||||||
|
rundown,
|
||||||
|
playableEventOrder,
|
||||||
|
cue,
|
||||||
|
state.runtime.selectedEventIndex ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = this.loadEvent(event);
|
const loaded = this.loadEvent(event);
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return false;
|
return false;
|
||||||
@@ -392,7 +415,11 @@ class RuntimeService {
|
|||||||
*/
|
*/
|
||||||
@broadcastResult
|
@broadcastResult
|
||||||
public loadByIndex(eventIndex: number): boolean {
|
public loadByIndex(eventIndex: number): boolean {
|
||||||
const event = getEventAtIndex(eventIndex);
|
const rundown = getCurrentRundown();
|
||||||
|
const { timedEventOrder } = getRundownMetadata();
|
||||||
|
|
||||||
|
const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -406,10 +433,21 @@ class RuntimeService {
|
|||||||
*/
|
*/
|
||||||
@broadcastResult
|
@broadcastResult
|
||||||
public loadByCue(cue: string): boolean {
|
public loadByCue(cue: string): boolean {
|
||||||
const event = getNextEventWithCue(cue); //TODO: add index
|
const state = runtimeState.getState();
|
||||||
|
const rundown = getCurrentRundown();
|
||||||
|
const { playableEventOrder } = getRundownMetadata();
|
||||||
|
|
||||||
|
const event = findNextPlayableWithCue(
|
||||||
|
rundown,
|
||||||
|
playableEventOrder,
|
||||||
|
cue,
|
||||||
|
state.runtime.selectedEventIndex ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.loadEvent(event);
|
return this.loadEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,10 +459,16 @@ class RuntimeService {
|
|||||||
*/
|
*/
|
||||||
private handleLoadPrevious(): boolean {
|
private handleLoadPrevious(): boolean {
|
||||||
const state = runtimeState.getState();
|
const state = runtimeState.getState();
|
||||||
const previousEvent = findPrevious(state.eventNow?.id);
|
const { playableEventOrder } = getRundownMetadata();
|
||||||
if (previousEvent) {
|
|
||||||
return this.loadEvent(previousEvent);
|
const previousId = findPreviousPlayableId(playableEventOrder, state.eventNow?.id);
|
||||||
|
if (previousId) {
|
||||||
|
const previousEvent = getEntryWithId(previousId);
|
||||||
|
if (previousEvent && isOntimeEvent(previousEvent)) {
|
||||||
|
return this.loadEvent(previousEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,8 +490,15 @@ class RuntimeService {
|
|||||||
*/
|
*/
|
||||||
private handleLoadNext(): boolean {
|
private handleLoadNext(): boolean {
|
||||||
const state = runtimeState.getState();
|
const state = runtimeState.getState();
|
||||||
const nextEvent = findNext(state.eventNow?.id);
|
const { playableEventOrder } = getRundownMetadata();
|
||||||
if (nextEvent) {
|
|
||||||
|
const nextId = findNextPlayableId(playableEventOrder, state.eventNow?.id);
|
||||||
|
if (nextId) {
|
||||||
|
const nextEvent = getEntryWithId(nextId);
|
||||||
|
if (!nextEvent || !isOntimeEvent(nextEvent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.timer.playback === Playback.Roll) {
|
if (state.timer.playback === Playback.Roll) {
|
||||||
return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart });
|
return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart });
|
||||||
}
|
}
|
||||||
@@ -457,7 +508,6 @@ class RuntimeService {
|
|||||||
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
|
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads event after currently selected
|
* Loads event after currently selected
|
||||||
* @return {boolean} success
|
* @return {boolean} success
|
||||||
@@ -585,10 +635,10 @@ class RuntimeService {
|
|||||||
*/
|
*/
|
||||||
private rollLoaded(offset?: number) {
|
private rollLoaded(offset?: number) {
|
||||||
const rundown = getCurrentRundown();
|
const rundown = getCurrentRundown();
|
||||||
const { timedEventsOrder } = getEventOrder();
|
const { timedEventOrder } = getRundownMetadata();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runtimeState.roll(rundown, timedEventsOrder, offset);
|
runtimeState.roll(rundown, timedEventOrder, offset);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
||||||
}
|
}
|
||||||
@@ -608,8 +658,10 @@ class RuntimeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rundown, rundownOrder } = getRundownData();
|
const rundown = getCurrentRundown();
|
||||||
const result = runtimeState.roll(rundown, rundownOrder.timedEventsOrder);
|
const rundownMetadata = getRundownMetadata();
|
||||||
|
const result = runtimeState.roll(rundown, rundownMetadata.playableEventOrder);
|
||||||
|
|
||||||
const newState = runtimeState.getState();
|
const newState = runtimeState.getState();
|
||||||
if (result.eventId !== previousState.eventNow?.id) {
|
if (result.eventId !== previousState.eventNow?.id) {
|
||||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
|
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
|
||||||
@@ -660,8 +712,10 @@ class RuntimeService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rundown, rundownOrder } = getRundownData();
|
const rundown = getCurrentRundown();
|
||||||
runtimeState.resume(restorePoint, event, rundown, rundownOrder.timedEventsOrder);
|
const rundownMetadata = getRundownMetadata();
|
||||||
|
runtimeState.resume(restorePoint, event, rundown, rundownMetadata.playableEventOrder);
|
||||||
|
|
||||||
logger.info(LogOrigin.Playback, 'Resuming playback');
|
logger.info(LogOrigin.Playback, 'Resuming playback');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { millisToSeconds } from 'ontime-utils';
|
import { millisToSeconds } from 'ontime-utils';
|
||||||
import { MaybeNumber, TimerType } from 'ontime-types';
|
import { EntryId, isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeEvent, Rundown, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { timerConfig } from '../../setup/config.js';
|
import { timerConfig } from '../../setup/config.js';
|
||||||
|
|
||||||
@@ -33,3 +33,97 @@ export function getForceUpdate(previousUpdate: number, now: number): boolean {
|
|||||||
const hasExceededRate = now - previousUpdate >= timerConfig.notificationRate;
|
const hasExceededRate = now - previousUpdate >= timerConfig.notificationRate;
|
||||||
return isClockBehind || hasExceededRate;
|
return isClockBehind || hasExceededRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* finds the previous playable event, if it exists
|
||||||
|
*/
|
||||||
|
export function findPreviousPlayableId(playableEventsOrder: EntryId[], currentEventId?: string): EntryId | undefined {
|
||||||
|
if (!playableEventsOrder.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is no event running, go to first
|
||||||
|
if (!currentEventId) {
|
||||||
|
return getFirstPlayableId(playableEventsOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
|
||||||
|
|
||||||
|
if (currentIndex < 1) {
|
||||||
|
return getFirstPlayableId(playableEventsOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playableEventsOrder.at(currentIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* finds the next event playable event, if it exists
|
||||||
|
*/
|
||||||
|
export function findNextPlayableId(playableEventsOrder: EntryId[], currentEventId?: string): EntryId | undefined {
|
||||||
|
if (!playableEventsOrder.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is no event running, go to first
|
||||||
|
if (!currentEventId) {
|
||||||
|
return getFirstPlayableId(playableEventsOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId);
|
||||||
|
if (currentIndex === -1 || currentIndex >= playableEventsOrder.length - 1) {
|
||||||
|
return getFirstPlayableId(playableEventsOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playableEventsOrder.at(currentIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns first event that matches a given cue
|
||||||
|
*/
|
||||||
|
export function findNextPlayableWithCue(
|
||||||
|
rundown: Rundown,
|
||||||
|
playableEventsOrder: EntryId[],
|
||||||
|
targetCue: string,
|
||||||
|
currentEventIndex = 0,
|
||||||
|
): OntimeEvent | undefined {
|
||||||
|
const lowerCaseCue = targetCue.toLowerCase();
|
||||||
|
|
||||||
|
for (let i = currentEventIndex; i < playableEventsOrder.length; i++) {
|
||||||
|
const eventId = playableEventsOrder[i];
|
||||||
|
const event = rundown.entries[eventId];
|
||||||
|
if (isOntimeEvent(event) && isPlayableEvent(event) && event.cue.toLowerCase() === lowerCaseCue) {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility returns the first playable event in rundown, if it exists
|
||||||
|
*/
|
||||||
|
export function getFirstPlayableId(playableOrder: EntryId[]): EntryId | undefined {
|
||||||
|
return playableOrder.at(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a utility function to return an event at a given index
|
||||||
|
* It uses the timedEventOrder so that the index is the same as the one in the UI
|
||||||
|
*/
|
||||||
|
export function getEventAtIndex(
|
||||||
|
rundown: Rundown,
|
||||||
|
timedEventOrder: EntryId[],
|
||||||
|
eventIndex: number,
|
||||||
|
): OntimeEvent | undefined {
|
||||||
|
const eventId = timedEventOrder[eventIndex];
|
||||||
|
if (!eventId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rundown.entries[eventId] as OntimeEvent | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(v4): we dont need this function
|
||||||
|
*/
|
||||||
|
export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): OntimeEvent[] {
|
||||||
|
return timedEventOrder.map((id) => rundown.entries[id] as OntimeEvent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import { Credentials, OAuth2Client } from 'google-auth-library';
|
|||||||
// TODO: rewrite logic to use fetch and remove dependency
|
// TODO: rewrite logic to use fetch and remove dependency
|
||||||
import got from 'got';
|
import got from 'got';
|
||||||
|
|
||||||
import { parseExcel } from '../../utils/parser.js';
|
|
||||||
import { logger } from '../../classes/Logger.js';
|
import { logger } from '../../classes/Logger.js';
|
||||||
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
||||||
import { getCurrentRundown, getProjectCustomFields } from '../../api-data/rundown/rundown.dao.js';
|
import { getCurrentRundown, getProjectCustomFields } from '../../api-data/rundown/rundown.dao.js';
|
||||||
|
import { parseExcel } from '../../api-data/excel/excel.parser.js';
|
||||||
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
|
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
|
||||||
|
|
||||||
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
|
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
|
||||||
import { catchCommonImportXlsxError } from './googleApi.utils.js';
|
import { catchCommonImportXlsxError } from './googleApi.utils.js';
|
||||||
@@ -381,6 +380,12 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports a sheet as a rundown
|
||||||
|
* @throws if the client is not authenticated
|
||||||
|
* @throws if the response from Google Sheets fails
|
||||||
|
* @throws if the sheet does not contain any data
|
||||||
|
*/
|
||||||
export async function download(
|
export async function download(
|
||||||
sheetId: string,
|
sheetId: string,
|
||||||
options: ImportMap,
|
options: ImportMap,
|
||||||
@@ -418,10 +423,18 @@ export async function download(
|
|||||||
},
|
},
|
||||||
customFields: dataFromSheet.customFields,
|
customFields: dataFromSheet.customFields,
|
||||||
};
|
};
|
||||||
const { customFields, rundowns } = parseRundowns(dataModel);
|
|
||||||
const rundown = getRundownOrThrow(rundowns, rundownId);
|
const customFields = parseCustomFields(dataModel);
|
||||||
if (rundown.order.length < 1) {
|
const rundowns = parseRundowns(dataModel, customFields);
|
||||||
|
|
||||||
|
const importedRundown = rundowns[rundownId];
|
||||||
|
if (!importedRundown) {
|
||||||
|
throw new Error(`Sheet: Rundown with ID ${rundownId} not found in the worksheet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importedRundown.order.length < 1) {
|
||||||
throw new Error('Sheet: Could not find data to import in the worksheet');
|
throw new Error('Sheet: Could not find data to import in the worksheet');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rundown: rundowns[rundownId], customFields };
|
return { rundown: rundowns[rundownId], customFields };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import {
|
|||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
} from '../services/timerUtils.js';
|
} from '../services/timerUtils.js';
|
||||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
||||||
import { filterTimedEvents } from '../services/rundown-service/rundownUtils.js';
|
|
||||||
import { timerConfig } from '../setup/config.js';
|
import { timerConfig } from '../setup/config.js';
|
||||||
|
import { filterTimedEvents } from '../services/runtime-service/rundownService.utils.js';
|
||||||
|
|
||||||
export type RuntimeState = {
|
export type RuntimeState = {
|
||||||
clock: number; // realtime clock
|
clock: number; // realtime clock
|
||||||
@@ -182,22 +182,23 @@ export function updateRundownData(rundownData: RundownData) {
|
|||||||
export function load(
|
export function load(
|
||||||
event: PlayableEvent,
|
event: PlayableEvent,
|
||||||
rundown: Rundown,
|
rundown: Rundown,
|
||||||
timedEventsOrder: EntryId[],
|
timedEventOrder: EntryId[],
|
||||||
initialData?: Partial<TimerState & RestorePoint>,
|
initialData?: Partial<TimerState & RestorePoint>,
|
||||||
): boolean {
|
): boolean {
|
||||||
clearEventData();
|
clearEventData();
|
||||||
|
|
||||||
if (timedEventsOrder.length === 0 || !isPlayableEvent(event)) {
|
if (timedEventOrder.length === 0 || !isPlayableEvent(event)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter rundown
|
// filter rundown
|
||||||
const eventIndex = timedEventsOrder.findIndex((timedEventId) => timedEventId === event.id);
|
const eventIndex = timedEventOrder.findIndex((entryId) => entryId === event.id);
|
||||||
if (eventIndex === -1) {
|
if (eventIndex === -1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timedEvents = filterTimedEvents(rundown, timedEventsOrder);
|
// TODO(remove public): it is wasteful to recreate the object
|
||||||
|
const timedEvents = filterTimedEvents(rundown, timedEventOrder);
|
||||||
// load events in memory along with their data
|
// load events in memory along with their data
|
||||||
loadNow(timedEvents, eventIndex);
|
loadNow(timedEvents, eventIndex);
|
||||||
loadNext(timedEvents, eventIndex);
|
loadNext(timedEvents, eventIndex);
|
||||||
@@ -377,6 +378,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
|
|||||||
*/
|
*/
|
||||||
export function updateAll(rundown: Rundown, timedEventsOrder: EntryId[]) {
|
export function updateAll(rundown: Rundown, timedEventsOrder: EntryId[]) {
|
||||||
const timedEvents = filterTimedEvents(rundown, timedEventsOrder);
|
const timedEvents = filterTimedEvents(rundown, timedEventsOrder);
|
||||||
|
// TODO(remove public): we dont need to make the timedEvents object, we pass primitives and let the functions handle it
|
||||||
loadNow(timedEvents);
|
loadNow(timedEvents);
|
||||||
loadNext(timedEvents);
|
loadNext(timedEvents);
|
||||||
updateLoaded(runtimeState.eventNow ?? undefined);
|
updateLoaded(runtimeState.eventNow ?? undefined);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isEmptyObject, removeUndefined } from '../parserUtils.js';
|
import { isEmptyObject, makeString, removeUndefined } from '../parserUtils.js';
|
||||||
|
|
||||||
describe('isEmptyObject()', () => {
|
describe('isEmptyObject()', () => {
|
||||||
test('finds an empty object', () => {
|
test('finds an empty object', () => {
|
||||||
@@ -32,3 +32,39 @@ describe('removeUndefined()', () => {
|
|||||||
expect(removeUndefined(obj)).toStrictEqual(obj);
|
expect(removeUndefined(obj)).toStrictEqual(obj);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('makeString()', () => {
|
||||||
|
it('converts variables to string', () => {
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
val: 2,
|
||||||
|
expected: '2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
val: 2.22222222,
|
||||||
|
expected: '2.22222222',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
val: ['testing'],
|
||||||
|
expected: 'testing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
val: ' testing ',
|
||||||
|
expected: 'testing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
val: { doing: 'testing' },
|
||||||
|
expected: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
val: undefined,
|
||||||
|
expected: 'fallback',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
cases.forEach(({ val, expected }) => {
|
||||||
|
const converted = makeString(val, 'fallback');
|
||||||
|
expect(converted).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { isObject } from '../varUtils.js';
|
|
||||||
|
|
||||||
describe('isObject', () => {
|
|
||||||
const testCases = [1, 0, false, undefined, 'test', null, () => undefined, []];
|
|
||||||
testCases.forEach((test) => {
|
|
||||||
it(`recognises normal primitives ${test}`, () => {
|
|
||||||
const result = isObject(test);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -114,7 +114,7 @@ export async function dockerSafeRename(oldPath: PathLike, newPath: PathLike) {
|
|||||||
* finds potential file index number in our (*) format and increments
|
* finds potential file index number in our (*) format and increments
|
||||||
* the number section (*) must be separated from the name by a space
|
* the number section (*) must be separated from the name by a space
|
||||||
* @example incrementProjectNumber('test(1).json') -> 'test(1).json'
|
* @example incrementProjectNumber('test(1).json') -> 'test(1).json'
|
||||||
* @example incrementProjectNumber('test (1).json') -> 'test(2).json'
|
* @example incrementProjectNumber('test (1).json') -> 'test(2).json'
|
||||||
*/
|
*/
|
||||||
export function incrementProjectNumber(path: string): string {
|
export function incrementProjectNumber(path: string): string {
|
||||||
const { dir, name, ext } = parse(path);
|
const { dir, name, ext } = parse(path);
|
||||||
@@ -129,3 +129,12 @@ export function incrementProjectNumber(path: string): string {
|
|||||||
|
|
||||||
return join(dir, `${name.slice(0, openingParenIndex)} (${maybeNumber + 1})${ext}`);
|
return join(dir, `${name.slice(0, openingParenIndex)} (${maybeNumber + 1})${ext}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Delete file from system
|
||||||
|
*/
|
||||||
|
export const deleteFile = async (filePath: string) => {
|
||||||
|
return await unlink(filePath).catch((error) => {
|
||||||
|
console.error('Could not delete file:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { writeFileSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||||
import { get } from '../services/rundown-service/rundownCache.js';
|
|
||||||
import { getState } from '../stores/runtimeState.js';
|
import { getState } from '../stores/runtimeState.js';
|
||||||
import { publicDir } from '../setup/index.js';
|
import { publicDir } from '../setup/index.js';
|
||||||
|
|
||||||
import { ensureDirectory } from './fileManagement.js';
|
import { ensureDirectory } from './fileManagement.js';
|
||||||
|
import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js';
|
||||||
/**
|
/**
|
||||||
* Writes a file to the crash report location
|
* Writes a file to the crash report location
|
||||||
* @param fileName
|
* @param fileName
|
||||||
@@ -31,7 +31,7 @@ function writeToFile(fileName: string, content: object) {
|
|||||||
export function generateCrashReport(maybeError: unknown) {
|
export function generateCrashReport(maybeError: unknown) {
|
||||||
const timeNow = new Date().toISOString();
|
const timeNow = new Date().toISOString();
|
||||||
const runtimeState = getState();
|
const runtimeState = getState();
|
||||||
const rundownState = get();
|
const currentRundown = getCurrentRundown();
|
||||||
const error =
|
const error =
|
||||||
maybeError instanceof Error
|
maybeError instanceof Error
|
||||||
? {
|
? {
|
||||||
@@ -45,7 +45,7 @@ export function generateCrashReport(maybeError: unknown) {
|
|||||||
version: ONTIME_VERSION,
|
version: ONTIME_VERSION,
|
||||||
error,
|
error,
|
||||||
runtimeState,
|
runtimeState,
|
||||||
rundownState,
|
currentRundown,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeToFile(`crash-log-${timeNow}.log`, crashReport);
|
writeToFile(`crash-log-${timeNow}.log`, crashReport);
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import { CustomField, CustomFields, DatabaseModel, ProjectData, Settings, URLPreset, ViewSettings } from 'ontime-types';
|
|
||||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { dbModel } from '../models/dataModel.js';
|
|
||||||
|
|
||||||
import { type ErrorEmitter } from './parser.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse event portion of an entry
|
|
||||||
*/
|
|
||||||
export function parseProject(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ProjectData {
|
|
||||||
if (!data.project) {
|
|
||||||
emitError?.('No data found to import');
|
|
||||||
return { ...dbModel.project };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Found project data, importing...');
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: data.project.title ?? dbModel.project.title,
|
|
||||||
description: data.project.description ?? dbModel.project.description,
|
|
||||||
publicUrl: data.project.publicUrl ?? dbModel.project.publicUrl,
|
|
||||||
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
|
|
||||||
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
|
|
||||||
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
|
|
||||||
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
|
|
||||||
custom: data.project.custom ?? dbModel.project.custom,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse settings portion of an entry
|
|
||||||
*/
|
|
||||||
export function parseSettings(data: Partial<DatabaseModel>): Settings {
|
|
||||||
// skip if file definition is missing
|
|
||||||
// TODO: skip parsing if the version is not correct
|
|
||||||
if (!data.settings || data.settings?.version == null) {
|
|
||||||
throw new Error('ERROR: unable to parse settings, missing or incorrect version');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Found settings, importing...');
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: dbModel.settings.version,
|
|
||||||
serverPort: data.settings.serverPort ?? dbModel.settings.serverPort,
|
|
||||||
editorKey: data.settings.editorKey ?? null,
|
|
||||||
operatorKey: data.settings.operatorKey ?? null,
|
|
||||||
timeFormat: data.settings.timeFormat ?? '24',
|
|
||||||
language: data.settings.language ?? 'en',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse view settings portion of an entry
|
|
||||||
*/
|
|
||||||
export function parseViewSettings(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ViewSettings {
|
|
||||||
if (!data.viewSettings) {
|
|
||||||
emitError?.('No data found to import');
|
|
||||||
return { ...dbModel.viewSettings };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Found view settings, importing...');
|
|
||||||
|
|
||||||
return {
|
|
||||||
dangerColor: data.viewSettings.dangerColor ?? dbModel.viewSettings.dangerColor,
|
|
||||||
endMessage: data.viewSettings.endMessage ?? dbModel.viewSettings.endMessage,
|
|
||||||
freezeEnd: data.viewSettings.freezeEnd ?? dbModel.viewSettings.freezeEnd,
|
|
||||||
normalColor: data.viewSettings.normalColor ?? dbModel.viewSettings.normalColor,
|
|
||||||
overrideStyles: data.viewSettings.overrideStyles ?? dbModel.viewSettings.overrideStyles,
|
|
||||||
warningColor: data.viewSettings.warningColor ?? dbModel.viewSettings.warningColor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse URL preset portion of an entry
|
|
||||||
*/
|
|
||||||
export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): URLPreset[] {
|
|
||||||
if (!data.urlPresets) {
|
|
||||||
emitError?.('No data found to import');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Found URL presets, importing...');
|
|
||||||
|
|
||||||
const newPresets: URLPreset[] = [];
|
|
||||||
|
|
||||||
for (const preset of data.urlPresets) {
|
|
||||||
const newPreset = {
|
|
||||||
enabled: preset.enabled ?? false,
|
|
||||||
alias: preset.alias ?? '',
|
|
||||||
pathAndParams: preset.pathAndParams ?? '',
|
|
||||||
};
|
|
||||||
newPresets.push(newPreset);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Uploaded ${newPresets.length} preset(s)`);
|
|
||||||
|
|
||||||
return newPresets;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse customFields entry
|
|
||||||
*/
|
|
||||||
export function parseCustomFields(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): CustomFields {
|
|
||||||
if (typeof data.customFields !== 'object') {
|
|
||||||
emitError?.('No data found to import');
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
console.log('Found Custom Fields, importing...');
|
|
||||||
|
|
||||||
const customFields = sanitiseCustomFields(data.customFields);
|
|
||||||
if (Object.keys(customFields).length !== Object.keys(data.customFields).length) {
|
|
||||||
emitError?.('Skipped invalid custom fields');
|
|
||||||
}
|
|
||||||
return customFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitiseCustomFields(data: object): CustomFields {
|
|
||||||
const newCustomFields: CustomFields = {};
|
|
||||||
|
|
||||||
for (const [originalKey, field] of Object.entries(data)) {
|
|
||||||
if (!isValidField(field)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAlphanumericWithSpace(field.label)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test label and key cohesion
|
|
||||||
const key = (() => {
|
|
||||||
const keyFromLabel = customFieldLabelToKey(field.label);
|
|
||||||
if (keyFromLabel === null) {
|
|
||||||
return originalKey;
|
|
||||||
}
|
|
||||||
return originalKey.toLowerCase() === keyFromLabel.toLowerCase() ? originalKey : keyFromLabel;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (key in newCustomFields) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
newCustomFields[key] = {
|
|
||||||
type: field.type,
|
|
||||||
colour: field.colour,
|
|
||||||
label: field.label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidField(data: unknown): data is CustomField {
|
|
||||||
return (
|
|
||||||
typeof data === 'object' &&
|
|
||||||
data !== null &&
|
|
||||||
'label' in data &&
|
|
||||||
data.label !== '' &&
|
|
||||||
'colour' in data &&
|
|
||||||
typeof data.colour === 'string' &&
|
|
||||||
'type' in data &&
|
|
||||||
(data.type === 'string' || data.type === 'image')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newCustomFields;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { unlink } from 'fs';
|
export type ErrorEmitter = (message: string) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Ensures variable is string, it skips object types
|
* @description Ensures variable is string, it skips object types
|
||||||
@@ -12,17 +12,6 @@ export const makeString = (val: unknown, fallback = ''): string => {
|
|||||||
return val.toString().trim();
|
return val.toString().trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Delete file from system
|
|
||||||
*/
|
|
||||||
export const deleteFile = async (filePath: string) => {
|
|
||||||
unlink(filePath, (error) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('Could not delete file:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Verifies if object is empty
|
* @description Verifies if object is empty
|
||||||
* @param {object} obj
|
* @param {object} obj
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export function isObject(variable: unknown): boolean {
|
|
||||||
return typeof variable === 'object' && variable !== null && !Array.isArray(variable);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
export type CustomFieldLabel = string;
|
export type CustomFieldKey = string;
|
||||||
|
|
||||||
export type CustomField = {
|
export type CustomField = {
|
||||||
type: 'string' | 'image';
|
type: 'string' | 'image';
|
||||||
colour: string;
|
colour: string;
|
||||||
label: CustomFieldLabel;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomFields = Record<CustomFieldLabel, CustomField>;
|
export type CustomFields = Record<CustomFieldKey, CustomField>;
|
||||||
export type EntryCustomFields = Record<CustomFieldLabel, string>;
|
export type EntryCustomFields = Record<CustomFieldKey, string>;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export type { URLPreset } from './definitions/core/UrlPreset.type.js';
|
|||||||
export type {
|
export type {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
CustomField,
|
CustomField,
|
||||||
CustomFieldLabel,
|
CustomFieldKey,
|
||||||
EntryCustomFields,
|
EntryCustomFields,
|
||||||
} from './definitions/core/CustomFields.type.js';
|
} from './definitions/core/CustomFields.type.js';
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export {
|
|||||||
getPreviousBlockNormal,
|
getPreviousBlockNormal,
|
||||||
swapEventData,
|
swapEventData,
|
||||||
} from './src/rundown-utils/rundownUtils.js';
|
} from './src/rundown-utils/rundownUtils.js';
|
||||||
|
export { getFirstRundown } from './src/rundown/rundown.utils.js';
|
||||||
|
|
||||||
// time format utils
|
// time format utils
|
||||||
export {
|
export {
|
||||||
@@ -53,7 +54,7 @@ export { isAlphanumeric, isAlphanumericWithSpace } from './src/regex-utils/isAlp
|
|||||||
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
||||||
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
||||||
|
|
||||||
export { customFieldLabelToKey, customKeyFromLabel } from './src/customField-utils/customFieldLabelToKey.js';
|
export { customFieldLabelToKey, customKeyFromLabel } from './src/customField-utils/customFieldUtils.js';
|
||||||
|
|
||||||
// helpers from externals
|
// helpers from externals
|
||||||
export { deepmerge } from './src/externals/deepmerge.js';
|
export { deepmerge } from './src/externals/deepmerge.js';
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { CustomFields } from 'ontime-types';
|
|
||||||
|
|
||||||
import { isAlphanumericWithSpace } from '../regex-utils/isAlphanumeric.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Transforms a Custom field label into a valid key or returns null if not possible
|
|
||||||
*/
|
|
||||||
export const customFieldLabelToKey = (label: string): string | null => {
|
|
||||||
if (isAlphanumericWithSpace(label)) {
|
|
||||||
return label.trim().replaceAll(' ', '_');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const customKeyFromLabel = (label: string, fields: CustomFields): string | null => {
|
|
||||||
const maybeMatchingKey = Object.keys(fields).find((key) => fields[key].label === label);
|
|
||||||
if (maybeMatchingKey) {
|
|
||||||
return maybeMatchingKey;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms an alphanumeric label with spaces into a valid key
|
||||||
|
*/
|
||||||
|
export function customFieldLabelToKey(label: string): string {
|
||||||
|
return label.trim().replaceAll(' ', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an object key in the CustomFields object that matches the given label
|
||||||
|
*/
|
||||||
|
export function customKeyFromLabel(label: string, fields: CustomFields): string | null {
|
||||||
|
const maybeMatchingKey = Object.keys(fields).find((key) => fields[key].label === label);
|
||||||
|
if (maybeMatchingKey) {
|
||||||
|
return maybeMatchingKey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ProjectRundowns, Rundown } from 'ontime-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the first rundown in the project
|
||||||
|
* We know that the project has at least one rundown
|
||||||
|
*/
|
||||||
|
export function getFirstRundown(rundowns: ProjectRundowns): Rundown {
|
||||||
|
const keys = Object.keys(rundowns);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
throw new Error('No rundowns found in project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstKey = keys[0];
|
||||||
|
return rundowns[firstKey];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user