mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 09:23:51 +00:00
Revert "Session version" (#1649)
* Revert "refactor: add version to session endpoint" This reverts commitcd40a6e55e. * Revert "Remove public event feature (#1645)" This reverts commit08d9e24871. * Revert "Fix: rearrange playing event (#1640)" This reverts commit0649678dca. * Revert "refactor: style tweaks to edit css modal" This reverts commita00ec2d02a. * Revert "refactor: remove usages of framer-motion" This reverts commit54a74ccc2a. * Revert "Upgrade expressjs (#1633)" This reverts commit6f3ab274bd. * Revert "Refactor: require trigger in all events objects (#1636)" This reverts commit90870ecfb6. * Revert "Fix: Correct boundary condition in applyDelay" This reverts commitb640e0e181. * Revert "let vite be the proxy to the dev server (#1630)" This reverts commitc41fe824cf. * Revert "refactor: migrate custom fields to transactions" This reverts commit62c8319d70. * Revert "refactor: simplify validations" This reverts commitb1d23467a2. * Revert "refactor: create transaction system and apply to adding entry (#1620)" This reverts commit9a62daf047. * Revert "Refactor: better rounding (#1594)" This reverts commitb9ab1c6fd7. * Revert "refactor: improve reorder logic" This reverts commitc1054711b0. * Revert "chore: simplify URLs" This reverts commita4d4f29a37. * Revert "refactor: order is single source of truth" This reverts commit2793aadea0. * Revert "refactor: refetch targets is enum" This reverts commite7cfb7d9d9. * Revert "refactor: small ux improvements" This reverts commit256a851c9b. * Revert "refactor: remove trivially inferred numEvents" This reverts commit4ab9c81cb8. * Revert "feat: duplicate groups" This reverts commit2f13d6c89e. * Revert "feat: create group from entry selection" This reverts commitf1f7bad25e. * Revert "feat: create block from rundown empty" This reverts commita8b52a48f7. * Revert "fix: collapsed blocks dont render children" This reverts commitcd0999b2ab. * Revert "refactor: type cleanup and test improvements" This reverts commitb4c60f3f04. * Revert "feat: allow dissolving a block" This reverts commit5cefad3666. * Revert "fix: uncontrolled prop on controlled component" This reverts commit7a6ecd8c34. * Revert "refactor: improve return of reorder" This reverts commitba96ecfd91. * Revert "refactor: mutations on batch elements must have IDs" This reverts commit7bed3757f2. * Revert "chore: upgrade dependencies" This reverts commite2e755b1d2. * Revert "refactor: extract utility to merge two arrays" This reverts commita77d23109d. * Revert "refactor: change network mode defaults" This reverts commit0eb3b8d382. * Revert "fix: delete nested events" This reverts commit0021185288. * Revert "fix: add event at end of block" This reverts commit94c72ff4f6. * Revert "refactor: make finder available in exported rundown" This reverts commite4c08dc9b2. * Revert "assert non null and update test (#1604)" This reverts commitec74af0d62. * Revert "Fix project renumber (#1597)" This reverts commitb6d72dd082. * Revert "Refactor: WebSocket from flush queue to one patch (#1595)" This reverts commit31c311daf0. * Revert "Refactor: ms for api calls (#1593)" This reverts commite9b3cc6090. * Revert "fix test (#1601)" This reverts commit543b04a097. * Revert "fix: rebase master" This reverts commitd39b85b6e6. * Revert "chore: correct test path" This reverts commitbbe107bb2b. * Revert "refactor: extract rundown parsing" This reverts commitc616240db1. * Revert "chore: improve convention entry <> event" This reverts commit166be66ce3. * Revert "refactor: maintain flat orders" This reverts commit4180d0a337. * Revert "refactor: implement operations on nested events" This reverts commit78108e316c. * Revert "refactor: process events in rundown" This reverts commit3bb8b70915. * Revert "chore: improve convention entry <> event" This reverts commit1c4f13a0ed. * Revert "chore: rename currentBlock > parent" This reverts commit3ca0abad53. * Revert "refactor: fix delay positioning in gaps" This reverts commit030c8f897f. * Revert "refactor(e2e): skip flaky test" This reverts commit68175cfa3b. * Revert "refactor: improve project loading" This reverts commitfd8f757851. * Revert "refactor: gather group metadata" This reverts commit876d111c61. * Revert "refactor: swap maintains schedule" This reverts commit730cb95c04. * Revert "chore: rename files" This reverts commit3c388d4fb5. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit2e23718d73. * Revert "refactor: clearer relationship on rundown elements" This reverts commit89ea8c470b. * Revert "refactor: use strict typing" This reverts commit178640bfc4. * Revert "refactor: remove stop as a possible end action" This reverts commit4ed38340e0. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit69eb9a5eff. * Revert "chore: remove IDE files" This reverts commit351425127a. * Revert "refactor: remove unused and legacy code" This reverts commit95f2ba37cc.
This commit is contained in:
committed by
GitHub
parent
ad6804019b
commit
722e045b20
@@ -1,34 +0,0 @@
|
||||
import { hasKeys, isArray, isDefined, isNumber, isObject, isString } from '../assert.js';
|
||||
|
||||
describe('assert utilities', () => {
|
||||
it('should assert strings', () => {
|
||||
expect(() => isString('hello')).not.toThrow();
|
||||
expect(() => isString(123)).toThrow('Unexpected payload type: 123');
|
||||
});
|
||||
|
||||
it('should assert numbers', () => {
|
||||
expect(() => isNumber(123)).not.toThrow();
|
||||
expect(() => isNumber('123')).toThrow('Unexpected payload type: 123');
|
||||
});
|
||||
|
||||
it('should assert defined values', () => {
|
||||
expect(() => isDefined('value')).not.toThrow();
|
||||
expect(() => isDefined(undefined)).toThrow('Payload not found');
|
||||
});
|
||||
|
||||
it('should assert objects', () => {
|
||||
expect(() => isObject({})).not.toThrow();
|
||||
expect(() => isObject(null)).toThrow('Unexpected payload type: null');
|
||||
expect(() => isObject([])).toThrow('Unexpected payload type: ');
|
||||
});
|
||||
|
||||
it('should assert objects with specific keys', () => {
|
||||
expect(() => hasKeys({ a: 1, b: 2 }, ['a', 'b'])).not.toThrow();
|
||||
expect(() => hasKeys({ a: 1 }, ['a', 'b'])).toThrow('Unexpected payload type: [object Object]');
|
||||
});
|
||||
|
||||
it('should assert arrays', () => {
|
||||
expect(() => isArray([1, 2, 3])).not.toThrow();
|
||||
expect(() => isArray('not an array')).toThrow('Unexpected payload type: not an array');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,7 @@
|
||||
import { describe, it, expect, Mock } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import {
|
||||
appendToName,
|
||||
ensureJsonExtension,
|
||||
generateUniqueFileName,
|
||||
incrementProjectNumber,
|
||||
} from '../fileManagement.js';
|
||||
import { appendToName, ensureJsonExtension, generateUniqueFileName } from '../fileManagement.js';
|
||||
|
||||
// Mock fs.existsSync to control the test environment
|
||||
vi.mock('fs', () => ({
|
||||
@@ -95,25 +90,3 @@ describe('generateUniqueFileName', () => {
|
||||
expect(uniqueFilename).toBe(expectedFilename);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file index', () => {
|
||||
it('sets index to 1 when there is no index', () => {
|
||||
expect(incrementProjectNumber('test file.json')).toBe('test file (1).json');
|
||||
});
|
||||
|
||||
it('increments to 2 when index is 1', () => {
|
||||
expect(incrementProjectNumber('test file (1).json')).toBe('test file (2).json');
|
||||
});
|
||||
|
||||
it('does not count number not wrapped in parenthesis', () => {
|
||||
expect(incrementProjectNumber('test file 1.json')).toBe('test file 1 (1).json');
|
||||
});
|
||||
|
||||
it('does not count number if there is not a space', () => {
|
||||
expect(incrementProjectNumber('test file(1).json')).toBe('test file(1) (1).json');
|
||||
});
|
||||
|
||||
it('counts multi digit numbers', () => {
|
||||
expect(incrementProjectNumber('test file (890).json')).toBe('test file (891).json');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
Settings,
|
||||
SupportedEvent,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
URLPreset,
|
||||
} from 'ontime-types';
|
||||
|
||||
import {
|
||||
parseCustomFields,
|
||||
parseProject,
|
||||
parseRundown,
|
||||
parseSettings,
|
||||
parseUrlPresets,
|
||||
parseViewSettings,
|
||||
sanitiseCustomFields,
|
||||
} from '../parserFunctions.js';
|
||||
|
||||
describe('parseRundown()', () => {
|
||||
it('returns an empty array if no rundown is given', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const result = parseRundown({}, errorEmitter);
|
||||
expect(result.rundown).toEqual([]);
|
||||
expect(result.customFields).toEqual({});
|
||||
expect(errorEmitter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('parses data, skipping invalid results', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const rundown = [
|
||||
{ id: '1', type: SupportedEvent.Event, title: 'test', skip: false }, // OK
|
||||
{ id: '1', type: SupportedEvent.Block, title: 'test 2', skip: false }, // duplicate ID
|
||||
{}, // no data
|
||||
{ id: '2', title: 'test 2', skip: false }, // no type
|
||||
] as OntimeRundown;
|
||||
const { rundown: parsedRundown } = parseRundown({ rundown, customFields: {} }, errorEmitter);
|
||||
expect(parsedRundown.length).toEqual(1);
|
||||
expect(parsedRundown.at(0)).toMatchObject({ id: '1', type: SupportedEvent.Event, title: 'test', skip: false });
|
||||
expect(errorEmitter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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: [],
|
||||
});
|
||||
expect(errorEmitter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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 and version', () => {
|
||||
const minimalSettings = { app: 'ontime', version: '1' } as Settings;
|
||||
const result = parseSettings({ settings: minimalSettings });
|
||||
expect(result).toBeTypeOf('object');
|
||||
});
|
||||
});
|
||||
|
||||
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()', () => {
|
||||
it('returns an a base model if nothing is given', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const result = parseCustomFields({}, errorEmitter);
|
||||
expect(result).toBeTypeOf('object');
|
||||
expect(errorEmitter).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('parses data, skipping invalid results', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
// @ts-expect-error -- data is external, we check bad types
|
||||
const customFields = {
|
||||
1: { label: 'test', type: 'string', colour: 'red' }, // ok
|
||||
2: { label: 'test', type: 'string' }, // duplicate label
|
||||
3: { label: '', type: 'string' }, // missing colour
|
||||
4: { type: 'string', colour: '' }, // missing label
|
||||
} as CustomFields;
|
||||
|
||||
const result = parseCustomFields({ customFields }, errorEmitter);
|
||||
expect(result).toMatchObject({
|
||||
test: {
|
||||
label: 'test',
|
||||
type: 'string',
|
||||
colour: 'red',
|
||||
},
|
||||
});
|
||||
expect(errorEmitter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitiseCustomFields()', () => {
|
||||
it('returns an empty object the type is incorrect', () => {
|
||||
expect(sanitiseCustomFields({})).toEqual({});
|
||||
});
|
||||
|
||||
it('returns an object of valid entries', () => {
|
||||
const customFields: CustomFields = {
|
||||
test: { label: 'test', type: 'string', colour: 'red' },
|
||||
test2: { label: 'test2', type: 'string', colour: 'green' },
|
||||
Test3: { label: 'Test3', type: 'string', colour: '' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual(customFields);
|
||||
});
|
||||
|
||||
it('type should be one of (image | string)', () => {
|
||||
const testTypes = sanitiseCustomFields({
|
||||
test1: { label: 'test1', type: 'another', colour: 'red' },
|
||||
test2: { label: 'test2', type: 'image', colour: 'red' },
|
||||
test3: { label: 'test3', type: 'string', colour: 'red' },
|
||||
});
|
||||
expect(testTypes).toMatchObject({
|
||||
test2: { label: 'test2', type: 'image', colour: 'red' },
|
||||
test3: { label: 'test3', type: 'string', colour: 'red' },
|
||||
});
|
||||
});
|
||||
|
||||
it('colour must be a string', () => {
|
||||
const customFields: CustomFields = {
|
||||
// @ts-expect-error intentional bad data
|
||||
test: { label: 'test', type: 'string', colour: 5 },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('label can not be empty', () => {
|
||||
const customFields: CustomFields = {
|
||||
'': { label: '', type: 'string', colour: 'red' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('remove extra stuff', () => {
|
||||
const customFields: CustomFields = {
|
||||
// @ts-expect-error intentional bad data
|
||||
test: { label: 'test', type: 'string', colour: 'red', extra: 'should be removed' },
|
||||
};
|
||||
const expectedCustomFields: CustomFields = {
|
||||
test: { label: 'test', type: 'string', colour: 'red' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
});
|
||||
|
||||
it('enforce name cohesion', () => {
|
||||
const customFields: CustomFields = {
|
||||
test: { label: 'NewName', type: 'string', colour: 'red' },
|
||||
};
|
||||
const expectedCustomFields: CustomFields = {
|
||||
NewName: { label: 'NewName', type: 'string', colour: 'red' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
});
|
||||
|
||||
it('allow old keys', () => {
|
||||
const customFields: CustomFields = {
|
||||
test: { label: 'Test', type: 'string', colour: 'red' },
|
||||
};
|
||||
const expectedCustomFields: CustomFields = {
|
||||
test: { label: 'Test', type: 'string', colour: 'red' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
});
|
||||
|
||||
it('labels with space', () => {
|
||||
const customFields: CustomFields = {
|
||||
Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' },
|
||||
};
|
||||
const expectedCustomFields: CustomFields = {
|
||||
Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
});
|
||||
|
||||
it('filters invalid entries', () => {
|
||||
const customFields: CustomFields = {
|
||||
test: { label: 'test', type: 'string', colour: 'red' },
|
||||
test2: { label: 'test2', type: 'string', colour: 'green' },
|
||||
bad: { label: '', type: 'string', colour: '' },
|
||||
Test3: { label: 'Test3', type: 'string', colour: '' },
|
||||
};
|
||||
const expectedCustomFields: CustomFields = {
|
||||
test: { label: 'test', type: 'string', colour: 'red' },
|
||||
test2: { label: 'test2', type: 'string', colour: 'green' },
|
||||
Test3: { label: 'Test3', type: 'string', colour: '' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRundown() linking', () => {
|
||||
it('returns linked events', () => {
|
||||
const data: Partial<DatabaseModel> = {
|
||||
rundown: [
|
||||
{
|
||||
id: '1',
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
],
|
||||
customFields: {},
|
||||
};
|
||||
|
||||
const result = parseRundown(data);
|
||||
expect(result.rundown[1]).toMatchObject({
|
||||
id: '2',
|
||||
linkStart: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unlinked if no previous', () => {
|
||||
const data: Partial<DatabaseModel> = {
|
||||
rundown: [
|
||||
{
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
],
|
||||
customFields: {},
|
||||
};
|
||||
|
||||
const result = parseRundown(data);
|
||||
expect(result.rundown[0]).toMatchObject({
|
||||
id: '2',
|
||||
linkStart: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns linked events past blocks and delays', () => {
|
||||
const data: Partial<DatabaseModel> = {
|
||||
rundown: [
|
||||
{
|
||||
id: '1',
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
id: 'delay1',
|
||||
type: SupportedEvent.Delay,
|
||||
duration: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
id: 'block1',
|
||||
type: SupportedEvent.Block,
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
],
|
||||
customFields: {},
|
||||
};
|
||||
|
||||
const result = parseRundown(data);
|
||||
expect(result.rundown[0]).toMatchObject({
|
||||
id: '1',
|
||||
cue: '1',
|
||||
});
|
||||
// skip delay
|
||||
expect(result.rundown[2]).toMatchObject({
|
||||
id: '2',
|
||||
cue: '2',
|
||||
linkStart: '1',
|
||||
});
|
||||
// skip block
|
||||
expect(result.rundown[4]).toMatchObject({
|
||||
id: '3',
|
||||
cue: '3',
|
||||
linkStart: '2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRundown() migrations', () => {
|
||||
const legacyEvent = {
|
||||
id: '1',
|
||||
type: SupportedEvent.Event,
|
||||
cue: '',
|
||||
title: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: 'time-to-end',
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockDuration,
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
isPublic: false,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
};
|
||||
|
||||
it('migrates an event with time-to-end', () => {
|
||||
const result = parseRundown({ rundown: [legacyEvent] as OntimeRundown });
|
||||
expect(result.rundown[0]).toMatchObject({
|
||||
id: '1',
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates an event without time-to-end', () => {
|
||||
const countdownEvent = { ...legacyEvent, timerType: TimerType.CountDown };
|
||||
const result = parseRundown({ rundown: [countdownEvent] as OntimeRundown });
|
||||
expect(result.rundown[0]).toMatchObject({
|
||||
id: '1',
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmptyObject, makeString, removeUndefined } from '../parserUtils.js';
|
||||
import { isEmptyObject, mergeObject, removeUndefined } from '../parserUtils.js';
|
||||
|
||||
describe('isEmptyObject()', () => {
|
||||
test('finds an empty object', () => {
|
||||
@@ -11,6 +11,92 @@ describe('isEmptyObject()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeObject()', () => {
|
||||
test('it suppresses undefined keys', () => {
|
||||
const a = {
|
||||
first: 'yes',
|
||||
second: 'yes',
|
||||
};
|
||||
const b = {
|
||||
first: undefined,
|
||||
second: 'no',
|
||||
};
|
||||
const merged = mergeObject(a, b);
|
||||
expect(merged).toStrictEqual({
|
||||
first: 'yes',
|
||||
second: 'no',
|
||||
});
|
||||
});
|
||||
test('it handles falsy values', () => {
|
||||
const a = {
|
||||
first: 'yes',
|
||||
second: 'yes' as string | null,
|
||||
third: 'yes',
|
||||
};
|
||||
const b = {
|
||||
first: 'no',
|
||||
second: null,
|
||||
third: '',
|
||||
};
|
||||
const merged = mergeObject(a, b);
|
||||
expect(merged).toStrictEqual({
|
||||
first: 'no',
|
||||
second: null,
|
||||
third: '',
|
||||
});
|
||||
});
|
||||
test('it only merges fields of the first object', () => {
|
||||
const a = {
|
||||
first: 'yes',
|
||||
second: 'yes',
|
||||
third: 'yes',
|
||||
};
|
||||
const b = {
|
||||
first: 0,
|
||||
second: null,
|
||||
third: '',
|
||||
forth: 'not-this',
|
||||
};
|
||||
// @ts-expect-error -- testing changing type
|
||||
const merged = mergeObject(a, b);
|
||||
expect(merged).toStrictEqual({
|
||||
first: 0,
|
||||
second: null,
|
||||
third: '',
|
||||
});
|
||||
});
|
||||
test('merges nested objects', () => {
|
||||
// Define a sample object with nested properties
|
||||
const a = {
|
||||
name: 'John',
|
||||
address: {
|
||||
city: 'New York',
|
||||
postalCode: '10001',
|
||||
},
|
||||
};
|
||||
|
||||
// Define a partial object with nested properties for merging
|
||||
const b = {
|
||||
name: 'Doe',
|
||||
address: {
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error -- testing missing property
|
||||
const merged = mergeObject(a, b);
|
||||
|
||||
expect(merged.name).toBe('Doe');
|
||||
expect(merged.address.city).toBe('San Francisco');
|
||||
// @ts-expect-error -- its ok, just checking
|
||||
expect(merged.address.state).toBe('CA');
|
||||
expect(merged.address.postalCode).toBe('10001');
|
||||
expect(merged.address).not.toBe(a.address);
|
||||
expect(merged.address).not.toBe(b.address);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUndefined()', () => {
|
||||
test('it removes undefined keys from object', () => {
|
||||
const obj = {
|
||||
@@ -32,39 +118,3 @@ describe('removeUndefined()', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,38 +2,68 @@ import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { parseExcelDate } from '../time.js';
|
||||
|
||||
describe('parseExcelDate', () => {
|
||||
// TODO: our parsing currently does not use UTC, so the tests can not be done in CI
|
||||
describe.todo('parses a valid date string as expected from excel', () => {
|
||||
test.each([
|
||||
['1899-12-30T00:00:00.000Z', 3600000],
|
||||
['1899-12-30T00:10:00.000Z', 4200000],
|
||||
['1899-12-30T01:00:00.000Z', 7200000],
|
||||
['1899-12-30T07:00:00.000Z', 28800000],
|
||||
['1899-12-30T08:00:10.000Z', 32410000],
|
||||
['1899-12-30T08:30:00.000Z', 34200000],
|
||||
])(`handles %s`, (fromExcel, expected) => {
|
||||
expect(parseExcelDate(fromExcel)).toBe(expected);
|
||||
});
|
||||
const testCases = [
|
||||
{
|
||||
fromExcel: '1899-12-30T00:00:00.000Z',
|
||||
expected: 3600000,
|
||||
},
|
||||
{
|
||||
fromExcel: '1899-12-30T00:10:00.000Z',
|
||||
expected: 4200000,
|
||||
},
|
||||
{
|
||||
fromExcel: '1899-12-30T01:00:00.000Z',
|
||||
expected: 7200000,
|
||||
},
|
||||
{
|
||||
fromExcel: '1899-12-30T07:00:00.000Z',
|
||||
expected: 28800000,
|
||||
},
|
||||
{
|
||||
fromExcel: '1899-12-30T08:00:10.000Z',
|
||||
expected: 32410000,
|
||||
},
|
||||
{
|
||||
fromExcel: '1899-12-30T08:30:00.000Z',
|
||||
expected: 34200000,
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of testCases) {
|
||||
it(`handles ${scenario.fromExcel}`, () => {
|
||||
expect(parseExcelDate(scenario.fromExcel)).toBe(scenario.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('parses a time string that passes validation', () => {
|
||||
test.each([['10:00:00'], ['10:00'], ['10:00AM'], ['10:00am'], ['10:00PM'], ['10:00pm']])(
|
||||
`handles %s`,
|
||||
(fromExcel) => {
|
||||
expect(parseExcelDate(fromExcel)).not.toBe(0);
|
||||
},
|
||||
);
|
||||
const validFields = ['10:00:00', '10:00', '10:00AM', '10:00am', '10:00PM', '10:00pm'];
|
||||
validFields.forEach((field) => {
|
||||
it(`handles ${field}`, () => {
|
||||
const millis = parseExcelDate(field);
|
||||
expect(millis).not.toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uses numeric fields as minutes', () => {
|
||||
test.each([[1], [10], [100]])(`handles numeric fields %s`, (fromExcel) => {
|
||||
expect(parseExcelDate(fromExcel)).toBe(fromExcel * MILLIS_PER_MINUTE);
|
||||
const invalidFields = [1, 10, 100];
|
||||
invalidFields.forEach((field) => {
|
||||
it(`handles ${field}`, () => {
|
||||
const millis = parseExcelDate(field);
|
||||
expect(millis).toBe(field * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns 0 on other strings', () => {
|
||||
test.each([['test'], [''], ['x']])(`handles invalid fields %s`, (fromExcel) => {
|
||||
expect(parseExcelDate(fromExcel)).toBe(0);
|
||||
const invalidFields = ['test', ''];
|
||||
invalidFields.forEach((field) => {
|
||||
it(`handles ${field}`, () => {
|
||||
const millis = parseExcelDate(field);
|
||||
expect(millis).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,29 @@
|
||||
import { is } from './is.js';
|
||||
|
||||
export function isString(value: unknown): asserts value is string {
|
||||
if (!is.string(value)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Unexpected payload type: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isDefined<T>(value: T | undefined): asserts value is T {
|
||||
if (!is.defined(value)) {
|
||||
if (value === undefined) {
|
||||
throw new Error('Payload not found');
|
||||
}
|
||||
}
|
||||
|
||||
export function isNumber(value: unknown): asserts value is number {
|
||||
if (!is.number(value)) {
|
||||
if (typeof value !== 'number') {
|
||||
throw new Error(`Unexpected payload type: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): asserts value is object {
|
||||
if (!is.object(value)) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`Unexpected payload type: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isArray(value: unknown): asserts value is unknown[] {
|
||||
if (!is.array(value)) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Unexpected payload type: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +32,9 @@ export function hasKeys<T extends object, K extends keyof any>(
|
||||
value: T,
|
||||
keys: K[],
|
||||
): asserts value is T & Record<K, unknown> {
|
||||
if (!is.objectWithKeys(value, keys)) {
|
||||
throw new Error(`Unexpected payload type: ${String(value)}`);
|
||||
for (const key of keys) {
|
||||
if (!(key in value)) {
|
||||
throw new Error(`Key not found: ${String(key)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, PathLike } from 'fs';
|
||||
import { readdir, copyFile, unlink } from 'fs/promises';
|
||||
import { basename, join, parse } from 'path';
|
||||
import { basename, extname, join, parse } from 'path';
|
||||
|
||||
/**
|
||||
* @description Creates a directory if it doesn't exist
|
||||
@@ -19,7 +19,7 @@ export function ensureDirectory(directory: string): void {
|
||||
/**
|
||||
* Ensures that a filename ends with .json extension
|
||||
*/
|
||||
export function ensureJsonExtension(filename: string): string {
|
||||
export function ensureJsonExtension(filename: string | undefined): string | undefined {
|
||||
if (!filename) return filename;
|
||||
return filename.endsWith('.json') ? filename : `${filename}.json`;
|
||||
}
|
||||
@@ -53,11 +53,16 @@ export function appendToName(filePath: string, append: string): string {
|
||||
* If a file with the same name already exists, appends a counter to the filename.
|
||||
*/
|
||||
export function generateUniqueFileName(directory: string, filename: string): string {
|
||||
const extension = extname(filename);
|
||||
const baseName = basename(filename, extension);
|
||||
|
||||
let counter = 0;
|
||||
let uniqueFilename = filename;
|
||||
|
||||
while (fileExists(uniqueFilename)) {
|
||||
counter++;
|
||||
// Append counter to filename if the file exists.
|
||||
uniqueFilename = incrementProjectNumber(uniqueFilename);
|
||||
uniqueFilename = `${baseName} (${counter})${extension}`;
|
||||
}
|
||||
|
||||
return uniqueFilename;
|
||||
@@ -101,7 +106,7 @@ export async function copyDirectory(src: string, dest: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* workaround avoids origin errors in docker deployments
|
||||
* EXDEV cross-device link not permitted
|
||||
*/
|
||||
@@ -109,32 +114,3 @@ export async function dockerSafeRename(oldPath: PathLike, newPath: PathLike) {
|
||||
await copyFile(oldPath, newPath);
|
||||
await unlink(oldPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* finds potential file index number in our (*) format and increments
|
||||
* 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(2).json'
|
||||
*/
|
||||
export function incrementProjectNumber(path: string): string {
|
||||
const { dir, name, ext } = parse(path);
|
||||
|
||||
if (!name.endsWith(')')) return join(dir, `${name} (1)${ext}`);
|
||||
|
||||
const openingParenIndex = name.lastIndexOf(' (');
|
||||
if (openingParenIndex === -1) return join(dir, `${name} (1)${ext}`);
|
||||
|
||||
const maybeNumber = Number(name.slice(openingParenIndex + 2, -1));
|
||||
if (isNaN(maybeNumber)) return join(dir, `${name} (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 { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||
import { get } from '../services/rundown-service/rundownCache.js';
|
||||
import { getState } from '../stores/runtimeState.js';
|
||||
import { publicDir } from '../setup/index.js';
|
||||
|
||||
import { ensureDirectory } from './fileManagement.js';
|
||||
import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js';
|
||||
/**
|
||||
* Writes a file to the crash report location
|
||||
* @param fileName
|
||||
@@ -31,7 +31,7 @@ function writeToFile(fileName: string, content: object) {
|
||||
export function generateCrashReport(maybeError: unknown) {
|
||||
const timeNow = new Date().toISOString();
|
||||
const runtimeState = getState();
|
||||
const currentRundown = getCurrentRundown();
|
||||
const rundownState = get();
|
||||
const error =
|
||||
maybeError instanceof Error
|
||||
? {
|
||||
@@ -45,7 +45,7 @@ export function generateCrashReport(maybeError: unknown) {
|
||||
version: ONTIME_VERSION,
|
||||
error,
|
||||
runtimeState,
|
||||
currentRundown,
|
||||
rundownState,
|
||||
};
|
||||
|
||||
writeToFile(`crash-log-${timeNow}.log`, crashReport);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const is = {
|
||||
string: (value: unknown): value is string => typeof value === 'string',
|
||||
number: (value: unknown): value is number => typeof value === 'number',
|
||||
defined: <T>(value: T | undefined): value is T => value !== undefined,
|
||||
object: (value: unknown): value is object => typeof value === 'object' && value !== null && !Array.isArray(value),
|
||||
objectWithKeys: <T extends object, K extends keyof any>(value: T, keys: K[]): value is T & Record<K, unknown> => {
|
||||
return keys.every((key) => key in value);
|
||||
},
|
||||
array: (value: unknown): value is unknown[] => Array.isArray(value),
|
||||
};
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
customFieldLabelToKey,
|
||||
customKeyFromLabel,
|
||||
defaultImportMap,
|
||||
generateId,
|
||||
type ImportMap,
|
||||
isKnownTimerType,
|
||||
validateEndAction,
|
||||
validateLinkStart,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
} from 'ontime-utils';
|
||||
import {
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
EventCustomFields,
|
||||
isOntimeBlock,
|
||||
LogOrigin,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
SupportedEvent,
|
||||
TimerType,
|
||||
TimeStrategy,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { parseAutomationSettings } from '../api-data/automation/automation.parser.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { event as eventDef } from '../models/eventsDefinition.js';
|
||||
|
||||
import { makeString } from './parserUtils.js';
|
||||
import { parseProject, parseRundown, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
|
||||
import { parseExcelDate } from './time.js';
|
||||
|
||||
export type ErrorEmitter = (message: string) => void;
|
||||
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
export const JSON_MIME = 'application/json';
|
||||
|
||||
type ExcelData = Pick<DatabaseModel, 'rundown' | 'customFields'> & {
|
||||
rundownMetadata: Record<string, { row: number; col: number }>;
|
||||
};
|
||||
|
||||
function parseBooleanString(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// falsy values would be nullish or empty string
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return value.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
export function getCustomFieldData(
|
||||
importMap: ImportMap,
|
||||
existingCustomFields: CustomFields,
|
||||
): {
|
||||
customFields: CustomFields;
|
||||
customFieldImportKeys: Record<keyof CustomFields, string>;
|
||||
} {
|
||||
const customFields = {};
|
||||
const customFieldImportKeys = {};
|
||||
for (const ontimeLabel in importMap.custom) {
|
||||
const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel);
|
||||
const importLabel = importMap.custom[ontimeLabel].toLowerCase();
|
||||
const colour = ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '';
|
||||
customFields[ontimeKey] = {
|
||||
type: 'string',
|
||||
colour,
|
||||
label: ontimeLabel,
|
||||
};
|
||||
customFieldImportKeys[importLabel] = ontimeKey;
|
||||
}
|
||||
return { customFields, customFieldImportKeys };
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Excel array parser
|
||||
* @param {array} excelData - array with excel sheet
|
||||
* @param {ImportOptions} options - an object that contains the import map
|
||||
* @returns {object} - parsed object
|
||||
*/
|
||||
export const parseExcel = (
|
||||
excelData: unknown[][],
|
||||
existingCustomFields: CustomFields,
|
||||
options?: Partial<ImportMap>,
|
||||
): ExcelData => {
|
||||
const rundownMetadata = {};
|
||||
const importMap: ImportMap = { ...defaultImportMap, ...options };
|
||||
|
||||
for (const [key, value] of Object.entries(importMap)) {
|
||||
if (typeof value === 'string') {
|
||||
importMap[key] = value.toLocaleLowerCase().trim();
|
||||
}
|
||||
}
|
||||
|
||||
const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
|
||||
const rundown: OntimeRundown = [];
|
||||
|
||||
// title stuff: strings
|
||||
let titleIndex: number | null = null;
|
||||
let cueIndex: number | null = null;
|
||||
let notesIndex: number | null = null;
|
||||
let colourIndex: number | null = null;
|
||||
|
||||
// options: booleans
|
||||
let isPublicIndex: number | null = null;
|
||||
let skipIndex: number | null = null;
|
||||
let countToEndIndex: number | null = null;
|
||||
|
||||
let linkStartIndex: number | null = null;
|
||||
|
||||
// times: numbers
|
||||
let timeStartIndex: number | null = null;
|
||||
let timeEndIndex: number | null = null;
|
||||
let durationIndex: number | null = null;
|
||||
let timeWarningIndex: number | null = null;
|
||||
let timeDangerIndex: number | null = null;
|
||||
|
||||
// options: enum properties
|
||||
let endActionIndex: number | null = null;
|
||||
let timerTypeIndex: number | null = null;
|
||||
|
||||
//ID
|
||||
let entryIdIndex: number | null = null;
|
||||
|
||||
// record of column index and the name of the field
|
||||
const customFieldIndexes: Record<number, string> = {};
|
||||
|
||||
excelData.forEach((row, rowIndex) => {
|
||||
if (row.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: extract generating handlers from importMap
|
||||
const handlers = {
|
||||
[importMap.timeStart]: (row: number, col: number) => {
|
||||
timeStartIndex = col;
|
||||
rundownMetadata['timeStart'] = { row, col };
|
||||
},
|
||||
[importMap.linkStart]: (row: number, col: number) => {
|
||||
linkStartIndex = col;
|
||||
rundownMetadata['linkStart'] = { row, col };
|
||||
},
|
||||
[importMap.timeEnd]: (row: number, col: number) => {
|
||||
timeEndIndex = col;
|
||||
rundownMetadata['timeEnd'] = { row, col };
|
||||
},
|
||||
[importMap.duration]: (row: number, col: number) => {
|
||||
durationIndex = col;
|
||||
rundownMetadata['duration'] = { row, col };
|
||||
},
|
||||
|
||||
[importMap.cue]: (row: number, col: number) => {
|
||||
cueIndex = col;
|
||||
rundownMetadata['cue'] = { row, col };
|
||||
},
|
||||
[importMap.title]: (row: number, col: number) => {
|
||||
titleIndex = col;
|
||||
rundownMetadata['title'] = { row, col };
|
||||
},
|
||||
[importMap.countToEnd]: (row: number, col: number) => {
|
||||
countToEndIndex = col;
|
||||
rundownMetadata['countToEnd'] = { row, col };
|
||||
},
|
||||
[importMap.isPublic]: (row: number, col: number) => {
|
||||
isPublicIndex = col;
|
||||
rundownMetadata['isPublic'] = { row, col };
|
||||
},
|
||||
[importMap.skip]: (row: number, col: number) => {
|
||||
skipIndex = col;
|
||||
rundownMetadata['skip'] = { row, col };
|
||||
},
|
||||
[importMap.note]: (row: number, col: number) => {
|
||||
notesIndex = col;
|
||||
rundownMetadata['note'] = { row, col };
|
||||
},
|
||||
[importMap.colour]: (row: number, col: number) => {
|
||||
colourIndex = col;
|
||||
rundownMetadata['colour'] = { row, col };
|
||||
},
|
||||
[importMap.endAction]: (row: number, col: number) => {
|
||||
endActionIndex = col;
|
||||
rundownMetadata['endAction'] = { row, col };
|
||||
},
|
||||
[importMap.timerType]: (row: number, col: number) => {
|
||||
timerTypeIndex = col;
|
||||
rundownMetadata['timerType'] = { row, col };
|
||||
},
|
||||
[importMap.timeWarning]: (row: number, col: number) => {
|
||||
timeWarningIndex = col;
|
||||
rundownMetadata['timeWarning'] = { row, col };
|
||||
},
|
||||
[importMap.timeDanger]: (row: number, col: number) => {
|
||||
timeDangerIndex = col;
|
||||
rundownMetadata['timeDanger'] = { row, col };
|
||||
},
|
||||
[importMap.entryId]: (row: number, col: number) => {
|
||||
entryIdIndex = col;
|
||||
rundownMetadata['id'] = { row, col };
|
||||
},
|
||||
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
|
||||
customFieldIndexes[col] = columnText;
|
||||
rundownMetadata[`custom:${ontimeKey}`] = { row, col };
|
||||
},
|
||||
} as const;
|
||||
|
||||
const event: any = {};
|
||||
const eventCustomFields: EventCustomFields = {};
|
||||
|
||||
for (let j = 0; j < row.length; j++) {
|
||||
const column = row[j];
|
||||
// 1. we check if we have set a flag for a known field
|
||||
if (j === timerTypeIndex) {
|
||||
const maybeTimeType = makeString(column, '');
|
||||
if (maybeTimeType === 'block') {
|
||||
event.type = SupportedEvent.Block;
|
||||
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
|
||||
event.type = SupportedEvent.Event;
|
||||
event.timerType = validateTimerType(maybeTimeType);
|
||||
} else {
|
||||
// if it is not a block or a known type, we dont import it
|
||||
return;
|
||||
}
|
||||
} else if (j === titleIndex) {
|
||||
event.title = makeString(column, '');
|
||||
} else if (j === timeStartIndex) {
|
||||
event.timeStart = parseExcelDate(column);
|
||||
} else if (j === linkStartIndex) {
|
||||
event.linkStart = parseBooleanString(column);
|
||||
} else if (j === timeEndIndex) {
|
||||
event.timeEnd = parseExcelDate(column);
|
||||
} else if (j === durationIndex) {
|
||||
event.duration = parseExcelDate(column);
|
||||
} else if (j === cueIndex) {
|
||||
event.cue = makeString(column, '');
|
||||
} else if (j === countToEndIndex) {
|
||||
event.countToEnd = parseBooleanString(column);
|
||||
} else if (j === isPublicIndex) {
|
||||
event.isPublic = parseBooleanString(column);
|
||||
} else if (j === skipIndex) {
|
||||
event.skip = parseBooleanString(column);
|
||||
} else if (j === notesIndex) {
|
||||
event.note = makeString(column, '');
|
||||
} else if (j === endActionIndex) {
|
||||
event.endAction = validateEndAction(column);
|
||||
} else if (j === timeWarningIndex) {
|
||||
event.timeWarning = parseExcelDate(column);
|
||||
} else if (j === timeDangerIndex) {
|
||||
event.timeDanger = parseExcelDate(column);
|
||||
} else if (j === colourIndex) {
|
||||
event.colour = makeString(column, '');
|
||||
} else if (j === entryIdIndex) {
|
||||
event.id = encodeURIComponent(makeString(column, undefined));
|
||||
} else if (j in customFieldIndexes) {
|
||||
const importKey = customFieldIndexes[j];
|
||||
const ontimeKey = customFieldImportKeys[importKey];
|
||||
eventCustomFields[ontimeKey] = makeString(column, '');
|
||||
} else {
|
||||
// 2. if there is no flag, lets see if we know the field type
|
||||
if (typeof column === 'string') {
|
||||
// we cant deal with empty content
|
||||
if (column.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const columnText = column.toLowerCase().trim();
|
||||
|
||||
// check if it is an ontime column
|
||||
if (handlers[columnText]) {
|
||||
handlers[columnText](rowIndex, j, undefined, undefined);
|
||||
}
|
||||
|
||||
// check if it is a custom field
|
||||
if (columnText in customFieldImportKeys) {
|
||||
const ontimeKey = customFieldImportKeys[columnText];
|
||||
handlers.custom(rowIndex, j, columnText, ontimeKey);
|
||||
}
|
||||
|
||||
// else. we don't know how to handle this column
|
||||
// just ignore it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if any data was found in row, push to array
|
||||
const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length;
|
||||
if (keysFound > 0) {
|
||||
// if it is a Block type drop all other filed
|
||||
if (isOntimeBlock(event)) {
|
||||
rundown.push({ type: event.type, id: event.id, title: event.title });
|
||||
} else {
|
||||
if (timerTypeIndex === null) {
|
||||
event.timerType = TimerType.CountDown;
|
||||
event.type = SupportedEvent.Event;
|
||||
}
|
||||
rundown.push({ ...event, custom: { ...eventCustomFields } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
rundown,
|
||||
customFields,
|
||||
rundownMetadata,
|
||||
};
|
||||
};
|
||||
|
||||
export type ParsingError = {
|
||||
context: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description handles parsing of ontime project file
|
||||
* @param {object} jsonData - project file to be parsed
|
||||
* @returns {object} - parsed object
|
||||
*/
|
||||
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
|
||||
// we need to parse settings first to make sure the data is ours
|
||||
// this may throw
|
||||
const settings = parseSettings(jsonData);
|
||||
|
||||
const errors: ParsingError[] = [];
|
||||
const makeEmitError = (context: string) => (message: string) => {
|
||||
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
||||
errors.push({ context, message });
|
||||
};
|
||||
|
||||
// we need to parse the custom fields first so they can be used in validating events
|
||||
// TODO: can we improve the readability of the error?
|
||||
const { rundown, customFields } = parseRundown(jsonData, makeEmitError('Rundown'));
|
||||
|
||||
const data: DatabaseModel = {
|
||||
rundown,
|
||||
project: parseProject(jsonData, makeEmitError('Project')),
|
||||
settings,
|
||||
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
|
||||
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
|
||||
customFields,
|
||||
automation: parseAutomationSettings(jsonData),
|
||||
};
|
||||
|
||||
return { data, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Function infers strategy for a patch with only partial timer data
|
||||
* @param end
|
||||
* @param duration
|
||||
* @param fallback
|
||||
* @returns
|
||||
*/
|
||||
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
|
||||
if (end && !duration) {
|
||||
return TimeStrategy.LockEnd;
|
||||
}
|
||||
|
||||
if (!end && duration) {
|
||||
return TimeStrategy.LockDuration;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
||||
if (Object.keys(patchEvent).length === 0) {
|
||||
return originalEvent;
|
||||
}
|
||||
|
||||
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
|
||||
patchEvent?.timeStart ?? originalEvent.timeStart,
|
||||
patchEvent?.timeEnd ?? originalEvent.timeEnd,
|
||||
patchEvent?.duration ?? originalEvent.duration,
|
||||
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
|
||||
);
|
||||
|
||||
return {
|
||||
id: originalEvent.id,
|
||||
type: SupportedEvent.Event,
|
||||
title: makeString(patchEvent.title, originalEvent.title),
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart: validateLinkStart(patchEvent.linkStart, originalEvent.linkStart),
|
||||
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
||||
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
||||
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
||||
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
|
||||
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
||||
note: makeString(patchEvent.note, originalEvent.note),
|
||||
colour: makeString(patchEvent.colour, originalEvent.colour),
|
||||
delay: originalEvent.delay, // is regenerated if timer related data is changed
|
||||
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
|
||||
gap: originalEvent.gap, // is regenerated if timer related data is changed
|
||||
// short circuit empty string
|
||||
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
||||
revision: originalEvent.revision,
|
||||
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
||||
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
||||
custom: { ...originalEvent.custom, ...patchEvent.custom },
|
||||
triggers: patchEvent.triggers ?? originalEvent.triggers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Enforces formatting for events
|
||||
* @param {object} eventArgs - attributes of event
|
||||
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
|
||||
* @returns {object|null} - formatted object or null in case is invalid
|
||||
*/
|
||||
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
|
||||
if (Object.keys(eventArgs).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
|
||||
|
||||
const baseEvent = {
|
||||
id: eventArgs?.id ?? generateId(),
|
||||
cue,
|
||||
...eventDef,
|
||||
};
|
||||
const event = createPatch(baseEvent, eventArgs);
|
||||
return event;
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
CustomField,
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
ProjectData,
|
||||
Settings,
|
||||
TimerType,
|
||||
URLPreset,
|
||||
ViewSettings,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
} from 'ontime-types';
|
||||
import { customFieldLabelToKey, generateId, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
||||
import { createEvent, type ErrorEmitter } from './parser.js';
|
||||
|
||||
/**
|
||||
* Parse rundown array of an entry
|
||||
*/
|
||||
export function parseRundown(
|
||||
data: Partial<DatabaseModel>,
|
||||
emitError?: ErrorEmitter,
|
||||
): { customFields: CustomFields; rundown: OntimeRundown } {
|
||||
// check custom fields first
|
||||
const parsedCustomFields = parseCustomFields(data, emitError);
|
||||
|
||||
if (!data.rundown) {
|
||||
emitError?.('No data found to import');
|
||||
return { customFields: parsedCustomFields, rundown: [] };
|
||||
}
|
||||
|
||||
console.log('Found rundown, importing...');
|
||||
|
||||
const rundown: OntimeRundown = [];
|
||||
let eventIndex = 0;
|
||||
let previousId: string | null = null;
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const event of data.rundown) {
|
||||
if (ids.includes(event.id)) {
|
||||
emitError?.('ID collision on event import, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = event.id || generateId();
|
||||
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
|
||||
|
||||
if (isOntimeEvent(event)) {
|
||||
const maybeEvent = runEventMigrations({ ...event, id });
|
||||
|
||||
if (event.linkStart) {
|
||||
maybeEvent.linkStart = previousId;
|
||||
}
|
||||
|
||||
newEvent = createEvent(maybeEvent, eventIndex);
|
||||
// skip if event is invalid
|
||||
if (newEvent == null) {
|
||||
emitError?.('Skipping event without payload');
|
||||
continue;
|
||||
}
|
||||
|
||||
// for every field in custom, check that a key exists in customfields
|
||||
for (const field in newEvent.custom) {
|
||||
if (!Object.hasOwn(parsedCustomFields, field)) {
|
||||
emitError?.(`Custom field ${field} not found`);
|
||||
delete newEvent.custom[field];
|
||||
}
|
||||
}
|
||||
|
||||
previousId = id;
|
||||
eventIndex += 1;
|
||||
} else if (isOntimeDelay(event)) {
|
||||
newEvent = { ...delayDef, duration: event.duration, id };
|
||||
} else if (isOntimeBlock(event)) {
|
||||
newEvent = { ...blockDef, title: event.title, id };
|
||||
} else {
|
||||
emitError?.('Unknown event type, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newEvent) {
|
||||
rundown.push(newEvent);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Uploaded rundown with ${rundown.length} entries`);
|
||||
return { customFields: parsedCustomFields, rundown };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (!data.settings || data.settings?.app !== 'ontime' || data.settings?.version == null) {
|
||||
throw new Error('ERROR: unable to parse settings, missing app or version');
|
||||
}
|
||||
|
||||
console.log('Found settings, importing...');
|
||||
|
||||
return {
|
||||
app: dbModel.settings.app,
|
||||
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;
|
||||
}
|
||||
|
||||
const keyFromLabel = customFieldLabelToKey(field.label);
|
||||
// Test label and key cohesion, but allow old lowercased keys to stay
|
||||
// TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024)
|
||||
const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time to end was moved from a TimerType to a standalone boolean named count to end
|
||||
* Released as part of v3.10.0
|
||||
*/
|
||||
function migrateTimeToEnd(event: any): OntimeEvent {
|
||||
if (event.timerType === 'time-to-end') {
|
||||
event.timerType = TimerType.CountDown;
|
||||
event.countToEnd = true;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutating function migrates event data entries
|
||||
*/
|
||||
function runEventMigrations(event: any): OntimeEvent {
|
||||
return migrateTimeToEnd(event);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ErrorEmitter = (message: string) => void;
|
||||
import { unlink } from 'fs';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* @description Ensures variable is string, it skips object types
|
||||
@@ -12,6 +13,17 @@ export const makeString = (val: unknown, fallback = ''): string => {
|
||||
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
|
||||
* @param {object} obj
|
||||
@@ -23,6 +35,33 @@ export const isEmptyObject = (obj: object) => {
|
||||
throw new Error('Variable is not an object');
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Merges two objects, suppressing undefined keys
|
||||
* @param {object} a - any object
|
||||
* @param {object} b - a potential partial object of same time as a
|
||||
*/
|
||||
export function mergeObject<T extends object>(a: T, b: Partial<T>): T {
|
||||
const merged = { ...a };
|
||||
|
||||
for (const key in b) {
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
// ignore keys that do not exist in original object
|
||||
if (!Object.hasOwn(merged, key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof bValue === 'object' && bValue !== null && typeof aValue === 'object' && aValue !== null) {
|
||||
// @ts-expect-error -- not sure how to type this
|
||||
merged[key] = deepmerge(aValue, bValue);
|
||||
} else if (bValue !== undefined) {
|
||||
merged[key] = bValue;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Removes undefined
|
||||
* @param {object} obj
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { parse } from 'path';
|
||||
|
||||
/**
|
||||
* @description Takes a filename and removes the extension
|
||||
* @param {string} filename - filename with extension
|
||||
*/
|
||||
export const removeFileExtension = (filename: string): string => {
|
||||
return parse(filename).name;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { isEmptyObject } from './parserUtils.js';
|
||||
|
||||
/**
|
||||
* @description initial checks for an empty of malformed request object
|
||||
* @param obj
|
||||
* @param res
|
||||
*/
|
||||
export const failEmptyObjects = (obj: object, res: Response): boolean => {
|
||||
try {
|
||||
if (isEmptyObject(obj)) {
|
||||
res.status(400).send('No object found in request');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description initial checks for an empty of malformed request object
|
||||
* @param obj
|
||||
* @param res
|
||||
*/
|
||||
export const failIsNotArray = (obj: object, res: Response): boolean => {
|
||||
try {
|
||||
if (!Array.isArray(obj)) {
|
||||
res.status(400).send('No array found in request');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -60,17 +60,3 @@ export function getTimezoneLabel(date: Date): string {
|
||||
|
||||
return `GMT ${sign}${pad(hours)}:${pad(minutes)} ${tzName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time from system
|
||||
*/
|
||||
export function timeNow() {
|
||||
const now = new Date();
|
||||
|
||||
// extract milliseconds since midnight
|
||||
let elapsed = now.getHours() * 3600000;
|
||||
elapsed += now.getMinutes() * 60000;
|
||||
elapsed += now.getSeconds() * 1000;
|
||||
elapsed += now.getMilliseconds();
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isObject(variable: unknown): boolean {
|
||||
return typeof variable === 'object' && variable !== null && !Array.isArray(variable);
|
||||
}
|
||||
Reference in New Issue
Block a user