mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 05:34:09 +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:
@@ -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';
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||
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 { 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(() => {
|
||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
setRundown: vi.fn().mockImplementation(() => undefined),
|
||||
setRundown: setRundownMock,
|
||||
setCustomFields: setCustomFieldsMock,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -24,17 +41,39 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('createTransaction', () => {
|
||||
it('should return a snapshot of the cached rundown and an commit function', () => {
|
||||
const { rundown, commit } = createTransaction();
|
||||
beforeEach(() => {
|
||||
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(customFields).toBeDefined();
|
||||
expect(typeof commit).toBe('function');
|
||||
});
|
||||
|
||||
it('should return the updated rundown after commit is called and update the db', () => {
|
||||
const { rundown, commit } = createTransaction();
|
||||
it('should return the updated data after commit is called and writes are scheduled', () => {
|
||||
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
|
||||
rundown.title = 'Another Title';
|
||||
customFields['newField'] = {
|
||||
label: 'New Field',
|
||||
type: 'string',
|
||||
colour: 'blue',
|
||||
};
|
||||
|
||||
const updated = commit();
|
||||
vi.runAllTimers();
|
||||
|
||||
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 { 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()', () => {
|
||||
it('returns a default project rundown if nothing is given', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const result = parseRundowns({}, errorEmitter);
|
||||
expect(result.customFields).toEqual({});
|
||||
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
|
||||
const result = parseRundowns({}, {}, errorEmitter);
|
||||
expect(result).toStrictEqual({ default: defaultRundown });
|
||||
// one for not having custom fields
|
||||
// one for not having a rundown
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(2);
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ensures the rundown IDs are consistent', () => {
|
||||
@@ -27,14 +26,14 @@ describe('parseRundowns()', () => {
|
||||
'3': r2,
|
||||
},
|
||||
},
|
||||
{},
|
||||
errorEmitter,
|
||||
);
|
||||
expect(result.rundowns).toMatchObject({
|
||||
expect(result).toMatchObject({
|
||||
'1': r1,
|
||||
'2': r2,
|
||||
});
|
||||
// one for not having a rundown
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(1);
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +182,37 @@ describe('parseRundown()', () => {
|
||||
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', () => {
|
||||
const rundown = {
|
||||
id: 'test',
|
||||
@@ -203,3 +233,50 @@ describe('parseRundown()', () => {
|
||||
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 { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
const array = ['id1', 'id2', 'id3', 'id4'];
|
||||
const result = deleteById(array, 'id2');
|
||||
@@ -156,3 +158,83 @@ describe('deleteById', () => {
|
||||
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 {
|
||||
CustomField,
|
||||
CustomFieldKey,
|
||||
CustomFields,
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
@@ -23,13 +25,11 @@ import {
|
||||
PatchWithId,
|
||||
Rundown,
|
||||
} 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 type { RundownMetadata } from './rundown.types.js';
|
||||
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
applyPatchToEntry,
|
||||
cloneBlock,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
doesInvalidateMetadata,
|
||||
getUniqueId,
|
||||
} from './rundown.utils.js';
|
||||
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
|
||||
|
||||
/**
|
||||
* The currently loaded rundown in cache
|
||||
@@ -62,8 +63,15 @@ let rundownMetadata: RundownMetadata = {
|
||||
playableEventOrder: [],
|
||||
timedEventOrder: [],
|
||||
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 = {};
|
||||
|
||||
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
|
||||
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
|
||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
||||
|
||||
export function createTransaction() {
|
||||
const rundown = structuredClone(cachedRundown);
|
||||
const customFields = projectCustomFields;
|
||||
type Transaction = {
|
||||
customFields: CustomFields;
|
||||
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) {
|
||||
// schedule a database update
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
});
|
||||
// if the rundown is mutable we persist the changes
|
||||
if (options.mutableRundown) {
|
||||
// schedule a database update
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
});
|
||||
|
||||
const revision = rundown.revision + 1;
|
||||
cachedRundown.revision = revision;
|
||||
// increment the revision number
|
||||
cachedRundown.revision = cachedRundown.revision + 1;
|
||||
|
||||
/**
|
||||
* Some mutations do not require processing the rundown
|
||||
* We simply increment the revision and return the rundown
|
||||
*/
|
||||
if (!shouldProcess) {
|
||||
cachedRundown.entries = rundown.entries;
|
||||
cachedRundown.order = rundown.order;
|
||||
cachedRundown.flatOrder = rundown.flatOrder;
|
||||
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
||||
/**
|
||||
* Some mutations do not require processing the rundown
|
||||
* We simply increment the revision and return the rundown
|
||||
*/
|
||||
if (!shouldProcess) {
|
||||
cachedRundown.title = rundown.title;
|
||||
cachedRundown.entries = rundown.entries;
|
||||
cachedRundown.order = rundown.order;
|
||||
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);
|
||||
// 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, ...metadata } = processedData;
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||
rundownMetadata = metadata;
|
||||
// if the customFields are mutable we persist the changes
|
||||
if (options.mutableCustomFields) {
|
||||
// schedule a database update
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setCustomFields(projectCustomFields);
|
||||
});
|
||||
|
||||
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
||||
projectCustomFields = customFields;
|
||||
}
|
||||
|
||||
return {
|
||||
rundown: cachedRundown,
|
||||
rundownMetadata,
|
||||
customFields: projectCustomFields,
|
||||
revision: cachedRundown.revision,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customFields,
|
||||
customFieldsMetadata,
|
||||
rundown,
|
||||
rundownMetadata,
|
||||
commit,
|
||||
};
|
||||
}
|
||||
@@ -329,7 +393,7 @@ function applyDelay(rundown: Rundown, delay: OntimeDelay) {
|
||||
/**
|
||||
* Swaps the data between two events
|
||||
* 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) {
|
||||
rundown.entries[eventFrom.id] = {
|
||||
@@ -484,6 +548,100 @@ export const rundownMutation = {
|
||||
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
|
||||
*/
|
||||
@@ -498,11 +656,13 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
||||
projectCustomFields = customFields;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
||||
processedData;
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||
cachedRundown.revision = rundown.revision;
|
||||
customFieldsMetadata.assigned = assignedCustomFields;
|
||||
rundownMetadata = metadata;
|
||||
|
||||
// defer writing to the database
|
||||
@@ -533,7 +693,7 @@ export function processRundown(
|
||||
initialRundown: Readonly<Rundown>,
|
||||
customFields: Readonly<CustomFields>,
|
||||
): ProcessedRundownMetadata {
|
||||
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
|
||||
const { process, getMetadata } = makeRundownMetadata(customFields);
|
||||
|
||||
for (let i = 0; i < initialRundown.order.length; i++) {
|
||||
// we assign a reference to the current entry, this will be mutated in place
|
||||
|
||||
@@ -9,15 +9,21 @@ import {
|
||||
isOntimeEvent,
|
||||
isOntimeDelay,
|
||||
isOntimeBlock,
|
||||
CustomFieldKey,
|
||||
EntryId,
|
||||
OntimeEntry,
|
||||
PlayableEvent,
|
||||
RundownEntries,
|
||||
isPlayableEvent,
|
||||
} 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 { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
|
||||
import { ErrorEmitter } from '../../utils/parser.js';
|
||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||
import type { ErrorEmitter } from '../../utils/parserUtils.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
|
||||
@@ -25,21 +31,16 @@ import { createEvent } from './rundown.utils.js';
|
||||
*/
|
||||
export function parseRundowns(
|
||||
data: Partial<DatabaseModel>,
|
||||
parsedCustomFields: Readonly<CustomFields>,
|
||||
emitError?: ErrorEmitter,
|
||||
): { customFields: CustomFields; rundowns: ProjectRundowns } {
|
||||
// check custom fields first
|
||||
const parsedCustomFields = parseCustomFields(data, emitError);
|
||||
|
||||
): ProjectRundowns {
|
||||
// ensure there is always a rundown to import
|
||||
// this is important since the rest of the app assumes this exist
|
||||
if (!data.rundowns || isObjectEmpty(data.rundowns)) {
|
||||
emitError?.('No data found to import');
|
||||
return {
|
||||
customFields: parsedCustomFields,
|
||||
rundowns: {
|
||||
default: {
|
||||
...defaultRundown,
|
||||
},
|
||||
[defaultRundown.id]: {
|
||||
...defaultRundown,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -55,7 +56,7 @@ export function parseRundowns(
|
||||
parsedRundowns[parsedRundown.id] = parsedRundown;
|
||||
}
|
||||
|
||||
return { customFields: parsedCustomFields, rundowns: parsedRundowns };
|
||||
return parsedRundowns;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +82,7 @@ export function parseRundown(
|
||||
const entryId = rundown.order[i];
|
||||
const event = rundown.entries[entryId];
|
||||
|
||||
if (event === undefined) {
|
||||
if (!event) {
|
||||
emitError?.('Could not find referenced event, skipping');
|
||||
continue;
|
||||
}
|
||||
@@ -170,3 +171,195 @@ export function parseRundown(
|
||||
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
|
||||
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 {
|
||||
CustomField,
|
||||
CustomFieldKey,
|
||||
CustomFields,
|
||||
EntryId,
|
||||
EventPostPayload,
|
||||
@@ -9,21 +11,21 @@ import {
|
||||
PatchWithId,
|
||||
Rundown,
|
||||
} from 'ontime-types';
|
||||
import { customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { getPreviousId } from '../../services/rundown-service/rundownUtils.js';
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
|
||||
import { createTransaction, rundownCache, rundownMutation } from './rundown.dao.js';
|
||||
import { RundownMetadata } from './rundown.types.js';
|
||||
import { generateEvent, hasChanges } from './rundown.utils.js';
|
||||
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js';
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
|
||||
|
||||
/**
|
||||
* creates a new entry with given data
|
||||
*/
|
||||
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
|
||||
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
|
||||
const afterId = getPreviousId(rundown, eventData?.after, eventData?.before);
|
||||
const afterId = getInsertAfterId(rundown, eventData?.after, eventData?.before);
|
||||
|
||||
// generate a fully formed entry from the patch
|
||||
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
|
||||
*/
|
||||
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];
|
||||
|
||||
/**
|
||||
@@ -115,7 +117,7 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
* Applies a patch to several entries in the 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
|
||||
@@ -179,7 +181,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
* Deletes a known entry from the current 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++) {
|
||||
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
|
||||
*/
|
||||
export async function deleteAllEntries(): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
rundownMutation.removeAll(rundown);
|
||||
|
||||
@@ -231,7 +233,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
||||
* @throws if entryId or destinationId not found
|
||||
*/
|
||||
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
|
||||
const eventFrom = rundown.entries[entryId];
|
||||
@@ -262,7 +264,7 @@ export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'b
|
||||
* The applied delay is deleted
|
||||
*/
|
||||
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
// check that delay exists
|
||||
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
|
||||
*/
|
||||
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 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
|
||||
*/
|
||||
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];
|
||||
|
||||
if (!originalEntry) {
|
||||
@@ -359,7 +361,7 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
|
||||
* Groups a list of entries into a new block
|
||||
*/
|
||||
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction();
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
rundownMutation.group(rundown, entryIds);
|
||||
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
|
||||
*/
|
||||
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];
|
||||
if (!block || !isOntimeBlock(block)) {
|
||||
@@ -402,6 +404,106 @@ export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
|
||||
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
|
||||
* 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 = {
|
||||
totalDelay: number;
|
||||
@@ -10,11 +10,9 @@ export type RundownMetadata = {
|
||||
playableEventOrder: EntryId[]; // flat order of playable events
|
||||
timedEventOrder: EntryId[]; // flat order of timed events
|
||||
flatEntryOrder: EntryId[]; // flat order of entries
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
assignedCustomFields: Record<CustomFieldLabel, string[]>;
|
||||
};
|
||||
|
||||
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
|
||||
export type CustomFieldsMetadata = {
|
||||
assigned: AssignedMap;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
SupportedEntry,
|
||||
TimeStrategy,
|
||||
} 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 { 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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user