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;
+};