From 686c6108bfd20ab4754e501aa57bbd18ffe25fda Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Thu, 30 May 2024 13:02:22 +0200 Subject: [PATCH] refactor: improve payload type for OSC (#954) --------- Co-authored-by: Joel Wetzell Co-authored-by: arc-alex --- apps/client/src/common/utils/regex.ts | 1 + .../integrations-panel/OscIntegrations.tsx | 6 +- .../integration-service/OscIntegration.ts | 17 ++--- .../integrationUtils.test.ts | 59 +++++++++++++++ .../src/utils/__tests__/oscArgParser.test.ts | 74 +++++++++++++++++++ apps/server/src/utils/oscArgParser.ts | 37 ++++++++++ .../src/definitions/core/OscSettings.type.ts | 2 +- packages/utils/index.ts | 1 + .../src/regex-utils/splitWhitespace.test.ts | 53 +++++++++++++ .../utils/src/regex-utils/splitWhitespace.ts | 37 ++++++++++ 10 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/utils/__tests__/oscArgParser.test.ts create mode 100644 apps/server/src/utils/oscArgParser.ts create mode 100644 packages/utils/src/regex-utils/splitWhitespace.test.ts create mode 100644 packages/utils/src/regex-utils/splitWhitespace.ts diff --git a/apps/client/src/common/utils/regex.ts b/apps/client/src/common/utils/regex.ts index 62fa4bb51..3f4848350 100644 --- a/apps/client/src/common/utils/regex.ts +++ b/apps/client/src/common/utils/regex.ts @@ -9,4 +9,5 @@ export const startsWithHttp = /^http:\/\//; export const startsWithSlash = /^\//; export const isAlphanumeric = /^[a-z0-9]+$/i; export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex +export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex export const isNotEmpty = /\S/; diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx index cd2584750..fe9b2511a 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx @@ -8,7 +8,7 @@ import { generateId } from 'ontime-utils'; import { maybeAxiosError } from '../../../../common/api/utils'; import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; import { isKeyEscape } from '../../../../common/utils/keyEvent'; -import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; +import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import * as Panel from '../PanelUtils'; import { cycles } from './integrationUtils'; @@ -221,7 +221,7 @@ export default function OscIntegrations() { Enabled Cycle Address - Payload + Arguments @@ -277,7 +277,7 @@ export default function OscIntegrations() { {...register(`subscriptions.${index}.payload`, { validate: { oscStringIsAscii: (value) => - isASCII.test(value) || 'OSC payloads only allow ASCII characters', + isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters', }, })} /> diff --git a/apps/server/src/services/integration-service/OscIntegration.ts b/apps/server/src/services/integration-service/OscIntegration.ts index 36334aa8c..8b7666709 100644 --- a/apps/server/src/services/integration-service/OscIntegration.ts +++ b/apps/server/src/services/integration-service/OscIntegration.ts @@ -3,9 +3,9 @@ import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } fro import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; import { parseTemplateNested } from './integrationUtils.js'; -import { isObject } from '../../utils/varUtils.js'; import { logger } from '../../classes/Logger.js'; import { OscServer } from '../../adapters/OscAdapter.js'; +import { stringToOSCArgs } from '../../utils/oscArgParser.js'; /** * @description Class contains logic towards outgoing OSC communications @@ -60,27 +60,24 @@ export class OscIntegration implements IIntegration { @@ -96,3 +97,61 @@ describe('parseNestedTemplate() -> resolveAliasData()', () => { expect(easyParse).toBe('5 to testing-3 {{human.not.found}}'); }); }); + +describe('parseNestedTemplate() -> stringToOSCArgs()', () => { + it('specific osc requirements', () => { + const data = { + not: { + so: { + easy: 'data with space', + empty: '', + number: 1234, + stringNumber: '1234', + }, + }, + }; + + const payloads = [ + { + test: '"string with space and {{not.so.easy}}"', + expect: [{ type: 'string', value: 'string with space and data with space' }], + }, + { + test: '"string with space and {{not.so.empty}}"', + expect: [{ type: 'string', value: 'string with space and ' }], + }, + { + test: '"string with space and {{not.so.number}}"', + expect: [{ type: 'string', value: 'string with space and 1234' }], + }, + { + test: '"string with space and {{not.so.stringNumber}}"', + expect: [{ type: 'string', value: 'string with space and 1234' }], + }, + { + test: '"{{not.so.easy}}" 1', + expect: [ + { type: 'string', value: 'data with space' }, + { type: 'integer', value: 1 }, + ], + }, + { + test: '"{{not.so.empty}}" 1', + expect: [ + { type: 'string', value: '' }, + { type: 'integer', value: 1 }, + ], + }, + { + test: '', + expect: [], + }, + ]; + + payloads.forEach((payload) => { + const parsedPayload = parseTemplateNested(payload.test, data); + const parsedArguments = stringToOSCArgs(parsedPayload); + expect(parsedArguments).toStrictEqual(payload.expect); + }); + }); +}); diff --git a/apps/server/src/utils/__tests__/oscArgParser.test.ts b/apps/server/src/utils/__tests__/oscArgParser.test.ts new file mode 100644 index 000000000..6b97e0707 --- /dev/null +++ b/apps/server/src/utils/__tests__/oscArgParser.test.ts @@ -0,0 +1,74 @@ +import { stringToOSCArgs } from '../oscArgParser.js'; + +describe('test stringToOSCArgs()', () => { + it('all types', () => { + const test = 'test 1111 0.1111 TRUE FALSE'; + const expected = [ + { type: 'string', value: 'test' }, + { type: 'integer', value: 1111 }, + { type: 'float', value: 0.1111 }, + { type: 'T', value: true }, + { type: 'F', value: false }, + ]; + expect(stringToOSCArgs(test)).toStrictEqual(expected); + }); + + it('empty is nothing', () => { + const test = undefined; + const expected = []; + expect(stringToOSCArgs(test)).toStrictEqual(expected); + }); + + it('empty is nothing', () => { + const test = ''; + const expected = []; + expect(stringToOSCArgs(test)).toStrictEqual(expected); + }); + + it('keep other types in strings', () => { + const test = 'test "1111" "0.1111" "TRUE" "FALSE"'; + const expected = [ + { type: 'string', value: 'test' }, + { type: 'string', value: '1111' }, + { type: 'string', value: '0.1111' }, + { type: 'string', value: 'TRUE' }, + { type: 'string', value: 'FALSE' }, + ]; + expect(stringToOSCArgs(test)).toStrictEqual(expected); + }); + + it('keep spaces in quoted strings', () => { + const test = '"test space" 1111 0.1111 TRUE FALSE'; + const expected = [ + { type: 'string', value: 'test space' }, + { type: 'integer', value: 1111 }, + { type: 'float', value: 0.1111 }, + { type: 'T', value: true }, + { type: 'F', value: false }, + ]; + expect(stringToOSCArgs(test)).toStrictEqual(expected); + }); + + it('keep spaces escaped quotes', () => { + const test = '"test \\" space" 1111 0.1111 TRUE FALSE'; + const expected = [ + { type: 'string', value: 'test " space' }, + { type: 'integer', value: 1111 }, + { type: 'float', value: 0.1111 }, + { type: 'T', value: true }, + { type: 'F', value: false }, + ]; + expect(stringToOSCArgs(test)).toStrictEqual(expected); + }); + + it('2 spaces', () => { + const test = '1111 0.1111 TRUE FALSE'; + const expected = [ + { type: 'integer', value: 1111 }, + { type: 'float', value: 0.1111 }, + { type: 'T', value: true }, + { type: 'F', value: false }, + ]; + expect(stringToOSCArgs(test)).toStrictEqual(expected); + }); +}); diff --git a/apps/server/src/utils/oscArgParser.ts b/apps/server/src/utils/oscArgParser.ts new file mode 100644 index 000000000..01bdf970a --- /dev/null +++ b/apps/server/src/utils/oscArgParser.ts @@ -0,0 +1,37 @@ +import { Argument } from 'node-osc'; +import { splitWhitespace } from 'ontime-utils'; + +export function stringToOSCArgs(argsString: string | undefined): Argument[] { + if (typeof argsString === 'undefined' || argsString === '') { + return new Array(); + } + const matches = splitWhitespace(argsString); + + const parsedArguments: Argument[] = matches.map((argString: string) => { + const argAsNum = Number(argString); + // NOTE: number like: 1 2.0 33333 + if (!Number.isNaN(argAsNum)) { + return { type: argString.includes('.') ? 'float' : 'integer', value: argAsNum }; + } + + if (argString.startsWith('"') && argString.endsWith('"')) { + // NOTE: "quoted string" + return { type: 'string', value: argString.substring(1, argString.length - 1) }; + } + + if (argString === 'TRUE') { + // NOTE: Boolean true + return { type: 'T', value: true }; + } + + if (argString === 'FALSE') { + // NOTE: Boolean false + return { type: 'F', value: false }; + } + + // NOTE: string + return { type: 'string', value: argString }; + }); + + return parsedArguments; +} diff --git a/packages/types/src/definitions/core/OscSettings.type.ts b/packages/types/src/definitions/core/OscSettings.type.ts index cc8d094b3..c006d65f1 100644 --- a/packages/types/src/definitions/core/OscSettings.type.ts +++ b/packages/types/src/definitions/core/OscSettings.type.ts @@ -4,7 +4,7 @@ export type OscSubscription = { id: string; cycle: TimerLifeCycleKey; address: string; - payload: string; + payload: string; // TODO: we should be using arguments to keep in line with protocol language enabled: boolean; }; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index ee8eb68ad..45768cd06 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -48,6 +48,7 @@ export { export { parseUserTime } from './src/date-utils/parseUserTime.js'; export { isAlphanumeric } from './src/regex-utils/isAlphanumeric.js'; export { isColourHex } from './src/regex-utils/isColourHex.js'; +export { splitWhitespace } from './src/regex-utils/splitWhitespace.js'; // helpers from externals export { deepmerge } from './src/externals/deepmerge.js'; diff --git a/packages/utils/src/regex-utils/splitWhitespace.test.ts b/packages/utils/src/regex-utils/splitWhitespace.test.ts new file mode 100644 index 000000000..060dd312f --- /dev/null +++ b/packages/utils/src/regex-utils/splitWhitespace.test.ts @@ -0,0 +1,53 @@ +import { splitWhitespace } from './splitWhitespace'; + +describe('test splitWhitespace() function', () => { + it('empty string', () => { + const test = ''; + expect(splitWhitespace(test)).toStrictEqual(null); + }); + + it('just space', () => { + const test = ' '; + expect(splitWhitespace(test)).toStrictEqual(null); + }); + + it('1 item', () => { + const test = 'test'; + expect(splitWhitespace(test)).toStrictEqual(['test']); + }); + + it('2 items', () => { + const test = 'test test'; + expect(splitWhitespace(test)).toStrictEqual(['test', 'test']); + }); + + it('2 items and quoted string', () => { + const test = 'test test "more test"'; + expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']); + }); + + it('2 sapces', () => { + const test = 'test test "more test"'; + expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']); + }); + + it('quotes without spaces', () => { + const test = 'test test "moreTest"'; + expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"moreTest"']); + }); + + it('escaped quotes', () => { + const test = 'test test "more \\" test"'; + expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more " test"']); + }); + + it('missing end quotes', () => { + const test = 'test test "more test'; + expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test']); + }); + + it('missing start quotes', () => { + const test = 'test test more test"'; + expect(splitWhitespace(test)).toStrictEqual(['test', 'test', 'more', 'test"']); + }); +}); diff --git a/packages/utils/src/regex-utils/splitWhitespace.ts b/packages/utils/src/regex-utils/splitWhitespace.ts new file mode 100644 index 000000000..f30c8f806 --- /dev/null +++ b/packages/utils/src/regex-utils/splitWhitespace.ts @@ -0,0 +1,37 @@ +const splitRegex = /\\?.|^$/g; + +/** + * adapted from {@link https://stackoverflow.com/questions/4031900/split-a-string-by-whitespace-keeping-quoted-segments-allowing-escaped-quotes this} + * @param str string to split + * @returns + */ +export const splitWhitespace = (str: string, keepQuotes = true): null | string[] => { + const match = str.match(splitRegex); + if (!match || match[0] == '') { + return null; + } + const array = match + .reduce( + (accumulator, current) => { + if (current === '"') { + accumulator.inQuotes ^= 1; + if (keepQuotes) { + accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1'); + } + } else if (!accumulator.inQuotes && current === ' ') { + accumulator.array.push(''); + } else { + accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1'); + } + return accumulator; + }, + { array: [''], inQuotes: 0 }, + ) + .array.filter((value) => value != ''); + + if (!array.length) { + return null; + } + + return array; +};