From ebf28ffb69ec9af6e93dbc7bb4113095a5f3d4f8 Mon Sep 17 00:00:00 2001 From: Volker Schukai <volker.schukai@schukai.com> Date: Sun, 2 Apr 2023 17:11:39 +0200 Subject: [PATCH] feat: new functions parseBracketedKeyValueHash and createBracketedKeyValueHash --- .../source/text/bracketed-key-value-hash.mjs | 245 ++++++++++++++++++ .../generate-range-comparison-expression.mjs | 93 +++++++ application/source/text/util.mjs | 86 +----- .../cases/text/bracketed-key-value-hash.mjs | 214 +++++++++++++++ 4 files changed, 553 insertions(+), 85 deletions(-) create mode 100644 application/source/text/bracketed-key-value-hash.mjs create mode 100644 application/source/text/generate-range-comparison-expression.mjs create mode 100644 development/test/cases/text/bracketed-key-value-hash.mjs diff --git a/application/source/text/bracketed-key-value-hash.mjs b/application/source/text/bracketed-key-value-hash.mjs new file mode 100644 index 000000000..dc5f39b6f --- /dev/null +++ b/application/source/text/bracketed-key-value-hash.mjs @@ -0,0 +1,245 @@ +/** + * Copyright schukai GmbH and contributors 2023. All Rights Reserved. + * Node module: @schukai/monster + * This file is licensed under the AGPLv3 License. + * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html + */ + +export {parseBracketedKeyValueHash, createBracketedKeyValueHash} + +/** + * Parses a string containing bracketed key-value pairs and returns an object representing the parsed result. + * + * - The string starts with a hash symbol #. + * - After the hash symbol, there are one or more selector strings, separated by a semicolon ;. + * - Each selector string has the format selectorName(key1=value1,key2=value2,...). + * - The selector name is a string of one or more alphanumeric characters. + * - The key-value pairs are separated by commas , and are of the form key=value. + * - The key is a string of one or more alphanumeric characters. + * - The value can be an empty string or a string of one or more characters. + * - If the value contains commas, it must be enclosed in double quotes ". + * - The entire key-value pair must be URL-encoded. + * - The closing parenthesis ) for each selector must be present, even if there are no key-value pairs. + * + * @example + * + * ```javascript + * // Example 1: + * const hashString = '#selector1(key1=value1,key2=value2);selector2(key3=value3)'; + * const result = parseBracketedKeyValueHash(hashString); + * // result => { selector1: { key1: "value1", key2: "value2" }, selector2: { key3: "value3" } } + * ``` + * + * @example + * + * ```javascript + * // Example 2: + * const hashString = '#selector1(key1=value1,key2=value2);selector2('; + * const result = parseBracketedKeyValueHash(hashString); + * // result => {} + * ``` + * + * @since 3.37.0 + * @param {string} hashString - The string to parse, containing bracketed key-value pairs. + * @returns {Object} - An object representing the parsed result, with keys representing the selectors and values representing the key-value pairs associated with each selector. + * - Returns an empty object if there was an error during parsing. */ +function parseBracketedKeyValueHash(hashString) { + const selectors = {}; + //const selectorStack = []; + //const keyValueStack = []; + + const trimmedHashString = hashString.trim(); + const cleanedHashString = trimmedHashString.charAt(0) === '#' ? trimmedHashString.slice(1) : trimmedHashString; + + + //const selectors = (keyValueStack.length > 0) ? result[selectorStack[selectorStack.length - 1]] : result; + let currentSelector = ""; + + function addToResult(key, value) { + if (currentSelector && key) { + if (!selectors[currentSelector]) { + selectors[currentSelector] = {}; + } + + selectors[currentSelector][key] = value; + } + } + + let currentKey = ''; + let currentValue = ''; + let inKey = true; + let inValue = false; + let inQuotedValue = false; + let inSelector = true; + let escaped = false; + let quotedValueStartChar = ''; + + for (let i = 0; i < cleanedHashString.length; i++) { + const c = cleanedHashString[i]; + const nextChar = cleanedHashString?.[i + 1]; + + if (c === '\\' && !escaped) { + escaped = true; + continue; + } + + if (escaped) { + if (inSelector) { + currentSelector += c; + } else if (inKey) { + currentKey += c; + } else if (inValue) { + currentValue += c; + } + escaped = false; + continue; + } + + if (inQuotedValue && quotedValueStartChar !== c) { + + if (inSelector) { + currentSelector += c; + } else if (inKey) { + currentKey += c; + } else if (inValue) { + currentValue += c; + } + + continue; + } + + if (c === ';' && inSelector) { + inSelector = true; + currentSelector = ""; + continue; + } + + + if (inSelector === true && c !== '(') { + currentSelector += c; + continue; + } + + if (c === '(' && inSelector) { + inSelector = false; + inKey = true; + + currentKey = ""; + continue; + } + + if (inKey === true && c !== '=') { + currentKey += c; + continue; + } + + if (c === '=' && inKey) { + + inKey = false; + inValue = true; + + if (nextChar === '"' || nextChar === "'") { + inQuotedValue = true; + quotedValueStartChar = nextChar; + i++; + continue; + } + + currentValue = ""; + continue; + } + + if (inValue === true) { + if (inQuotedValue) { + if (c === quotedValueStartChar) { + inQuotedValue = false; + continue; + } + + currentValue += c; + continue; + } + + if (c === ',') { + inValue = false; + inKey = true; + const decodedCurrentValue = decodeURIComponent(currentValue); + addToResult(currentKey, decodedCurrentValue); + currentKey = ""; + currentValue = ""; + continue; + } + + if (c === ')') { + inValue = false; + //inKey = true; + inSelector = true; + + const decodedCurrentValue = decodeURIComponent(currentValue); + addToResult(currentKey, decodedCurrentValue); + currentKey = ""; + currentValue = ""; + currentSelector = ""; + continue; + } + + currentValue += c; + + continue; + } + } + + + if (inSelector) { + return selectors; + } + + + return {}; + +} + +/** + * Creates a hash selector string from an object. + * + * @param {Object} object - The object containing selectors and key-value pairs. + * @param {boolean} addHashPrefix - Whether to add the hash prefix # to the beginning of the string. + * @returns {string} The hash selector string. + * @since 3.37.0 + */ +function createBracketedKeyValueHash(object, addHashPrefix = true) { + + if (!object) { + return addHashPrefix ? '#' : ''; + } + + let hashString = ''; + + function encodeKeyValue(key, value) { + return encodeURIComponent(key) + '=' + encodeURIComponent(value); + } + + for (const selector in object) { + if (object.hasOwnProperty(selector)) { + const keyValuePairs = object[selector]; + let selectorString = selector; + let keyValueString = ''; + + for (const key in keyValuePairs) { + if (keyValuePairs.hasOwnProperty(key)) { + const value = keyValuePairs[key]; + keyValueString += keyValueString.length === 0 ? '' : ','; + keyValueString += encodeKeyValue(key, value); + } + } + + if (keyValueString.length > 0) { + selectorString += '(' + keyValueString + ')'; + hashString += hashString.length === 0 ? '' : ';'; + hashString += selectorString; + } + } + } + + return addHashPrefix ? '#' + hashString : hashString; +} \ No newline at end of file diff --git a/application/source/text/generate-range-comparison-expression.mjs b/application/source/text/generate-range-comparison-expression.mjs new file mode 100644 index 000000000..367516e3c --- /dev/null +++ b/application/source/text/generate-range-comparison-expression.mjs @@ -0,0 +1,93 @@ +/** + * Copyright schukai GmbH and contributors 2023. All Rights Reserved. + * Node module: @schukai/monster + * This file is licensed under the AGPLv3 License. + * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html + */ + +export { generateRangeComparisonExpression }; + +/** + * The `generateRangeComparisonExpression()` function is function that generates a string representation + * of a comparison expression based on a range of values. It takes three arguments: + * + * - expression (required): a string representation of a range of values in the format of start1-end1,start2-end2,value3.... + * - valueName (required): a string representing the name of the value that is being compared to the range of values. + * - options (optional): an object containing additional options to customize the comparison expression. + * + * The generateRangeComparisonExpression() function returns a string representation of the comparison expression. + * + * ## Options + * The options parameter is an object that can have the following properties: + * + * urlEncode (boolean, default: false): if set to true, URL encodes the comparison operators. + * andOp (string, default: '&&'): the logical AND operator to use in the expression. + * orOp (string, default: '||'): the logical OR operator to use in the expression. + * eqOp (string, default: '=='): the equality operator to use in the expression. + * geOp (string, default: '>='): the greater than or equal to operator to use in the expression. + * leOp (string, default: '<='): the less than or equal to operator to use in the expression. + * + * Examples + * + * ```javascript + * const expression = '0-10,20-30'; + * const valueName = 'age'; + * const options = { urlEncode: true, andOp: 'and', orOp: 'or', eqOp: '=', geOp: '>=', leOp: '<=' }; + * const comparisonExpression = generateRangeComparisonExpression(expression, valueName, options); + * + * console.log(comparisonExpression); // age%3E%3D0%20and%20age%3C%3D10%20or%20age%3E%3D20%20and%20age%3C%3D30 + * ``` + * + * In this example, the generateRangeComparisonExpression() function generates a string representation of the comparison + * expression for the expression and valueName parameters with the specified options. The resulting comparison + * expression is 'age>=0 and age<=10 or age>=20 and age<=30', URL encoded according to the urlEncode option. + * + * @param {string} expression - The string expression to generate the comparison for. + * @param {string} valueName - The name of the value to compare against. + * @param {Object} [options] - The optional parameters. + * @param {boolean} [options.urlEncode=false] - Whether to encode comparison operators for use in a URL. + * @param {string} [options.andOp='&&'] - The logical AND operator to use. + * @param {string} [options.orOp='||'] - The logical OR operator to use. + * @param {string} [options.eqOp='=='] - The comparison operator for equality to use. + * @param {string} [options.geOp='>='] - The comparison operator for greater than or equal to to use. + * @param {string} [options.leOp='<='] - The comparison operator for less than or equal to to use. + * @returns {string} The generated comparison expression. + * @throws {Error} If the input is invalid. + * @memberOf Monster.Text + * @summary Generates a comparison expression based on a range of values. + */ +function generateRangeComparisonExpression(expression, valueName, options = {}) { + const { urlEncode = false, andOp = "&&", orOp = "||", eqOp = "==", geOp = ">=", leOp = "<=" } = options; + const ranges = expression.split(","); + let comparison = ""; + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i].trim(); + if (range === "") { + throw new Error(`Invalid range '${range}'`); + } else if (range.includes("-")) { + const [start, end] = range.split("-").map((s) => (s === "" ? null : parseFloat(s))); + if ((start !== null && isNaN(start)) || (end !== null && isNaN(end))) { + throw new Error(`Invalid value in range '${range}'`); + } + if (start !== null && end !== null && start > end) { + throw new Error(`Invalid range '${range}'`); + } + const compStart = + start !== null ? `${valueName}${urlEncode ? encodeURIComponent(geOp) : geOp}${start}` : ""; + const compEnd = end !== null ? `${valueName}${urlEncode ? encodeURIComponent(leOp) : leOp}${end}` : ""; + const compRange = `${compStart}${compStart && compEnd ? ` ${andOp} ` : ""}${compEnd}`; + comparison += ranges.length > 1 ? `(${compRange})` : compRange; + } else { + const value = parseFloat(range); + if (isNaN(value)) { + throw new Error(`Invalid value '${range}'`); + } + const compValue = `${valueName}${urlEncode ? encodeURIComponent(eqOp) : eqOp}${value}`; + comparison += ranges.length > 1 ? `(${compValue})` : compValue; + } + if (i < ranges.length - 1) { + comparison += ` ${orOp} `; + } + } + return comparison; +} diff --git a/application/source/text/util.mjs b/application/source/text/util.mjs index 64704efcc..29f2d90fb 100644 --- a/application/source/text/util.mjs +++ b/application/source/text/util.mjs @@ -1,86 +1,2 @@ -export { generateRangeComparisonExpression }; +export { generateRangeComparisonExpression } from "./generate-range-comparison-expression.mjs" -/** - * The `generateRangeComparisonExpression()` function is function that generates a string representation - * of a comparison expression based on a range of values. It takes three arguments: - * - * - expression (required): a string representation of a range of values in the format of start1-end1,start2-end2,value3.... - * - valueName (required): a string representing the name of the value that is being compared to the range of values. - * - options (optional): an object containing additional options to customize the comparison expression. - * - * The generateRangeComparisonExpression() function returns a string representation of the comparison expression. - * - * ## Options - * The options parameter is an object that can have the following properties: - * - * urlEncode (boolean, default: false): if set to true, URL encodes the comparison operators. - * andOp (string, default: '&&'): the logical AND operator to use in the expression. - * orOp (string, default: '||'): the logical OR operator to use in the expression. - * eqOp (string, default: '=='): the equality operator to use in the expression. - * geOp (string, default: '>='): the greater than or equal to operator to use in the expression. - * leOp (string, default: '<='): the less than or equal to operator to use in the expression. - * - * Examples - * - * ```javascript - * const expression = '0-10,20-30'; - * const valueName = 'age'; - * const options = { urlEncode: true, andOp: 'and', orOp: 'or', eqOp: '=', geOp: '>=', leOp: '<=' }; - * const comparisonExpression = generateRangeComparisonExpression(expression, valueName, options); - * - * console.log(comparisonExpression); // age%3E%3D0%20and%20age%3C%3D10%20or%20age%3E%3D20%20and%20age%3C%3D30 - * ``` - * - * In this example, the generateRangeComparisonExpression() function generates a string representation of the comparison - * expression for the expression and valueName parameters with the specified options. The resulting comparison - * expression is 'age>=0 and age<=10 or age>=20 and age<=30', URL encoded according to the urlEncode option. - * - * @param {string} expression - The string expression to generate the comparison for. - * @param {string} valueName - The name of the value to compare against. - * @param {Object} [options] - The optional parameters. - * @param {boolean} [options.urlEncode=false] - Whether to encode comparison operators for use in a URL. - * @param {string} [options.andOp='&&'] - The logical AND operator to use. - * @param {string} [options.orOp='||'] - The logical OR operator to use. - * @param {string} [options.eqOp='=='] - The comparison operator for equality to use. - * @param {string} [options.geOp='>='] - The comparison operator for greater than or equal to to use. - * @param {string} [options.leOp='<='] - The comparison operator for less than or equal to to use. - * @returns {string} The generated comparison expression. - * @throws {Error} If the input is invalid. - * @memberOf Monster.Text - * @summary Generates a comparison expression based on a range of values. - */ -function generateRangeComparisonExpression(expression, valueName, options = {}) { - const { urlEncode = false, andOp = "&&", orOp = "||", eqOp = "==", geOp = ">=", leOp = "<=" } = options; - const ranges = expression.split(","); - let comparison = ""; - for (let i = 0; i < ranges.length; i++) { - const range = ranges[i].trim(); - if (range === "") { - throw new Error(`Invalid range '${range}'`); - } else if (range.includes("-")) { - const [start, end] = range.split("-").map((s) => (s === "" ? null : parseFloat(s))); - if ((start !== null && isNaN(start)) || (end !== null && isNaN(end))) { - throw new Error(`Invalid value in range '${range}'`); - } - if (start !== null && end !== null && start > end) { - throw new Error(`Invalid range '${range}'`); - } - const compStart = - start !== null ? `${valueName}${urlEncode ? encodeURIComponent(geOp) : geOp}${start}` : ""; - const compEnd = end !== null ? `${valueName}${urlEncode ? encodeURIComponent(leOp) : leOp}${end}` : ""; - const compRange = `${compStart}${compStart && compEnd ? ` ${andOp} ` : ""}${compEnd}`; - comparison += ranges.length > 1 ? `(${compRange})` : compRange; - } else { - const value = parseFloat(range); - if (isNaN(value)) { - throw new Error(`Invalid value '${range}'`); - } - const compValue = `${valueName}${urlEncode ? encodeURIComponent(eqOp) : eqOp}${value}`; - comparison += ranges.length > 1 ? `(${compValue})` : compValue; - } - if (i < ranges.length - 1) { - comparison += ` ${orOp} `; - } - } - return comparison; -} diff --git a/development/test/cases/text/bracketed-key-value-hash.mjs b/development/test/cases/text/bracketed-key-value-hash.mjs new file mode 100644 index 000000000..a2bc319c1 --- /dev/null +++ b/development/test/cases/text/bracketed-key-value-hash.mjs @@ -0,0 +1,214 @@ +// test.js +import {expect} from "chai"; +import { + parseBracketedKeyValueHash, + createBracketedKeyValueHash +} from "../../../../application/source/text/bracketed-key-value-hash.mjs"; + +describe("parseBracketedKeyValueHash", () => { + it("should return an empty object for an empty string", () => { + const input = ""; + const expectedResult = {}; + expect(parseBracketedKeyValueHash(input)).to.deep.equal(expectedResult); + }); + + it("should parse a single selector with one key-value pair", () => { + const input = "#selector1(key1=value1)"; + const expectedResult = { + selector1: { + key1: "value1", + }, + }; + expect(parseBracketedKeyValueHash(input)).to.deep.equal(expectedResult); + }); + + it("should parse multiple selectors with multiple key-value pairs", () => { + const input = "#selector1(key1=value1,key2=value2);selector2(key3=value3,key4=value4)"; + const expectedResult = { + selector1: { + key1: "value1", + key2: "value2", + }, + selector2: { + key3: "value3", + key4: "value4", + }, + }; + expect(parseBracketedKeyValueHash(input)).to.deep.equal(expectedResult); + }); + + it("should decode URL-encoded values", () => { + const input = "#selector1(key1=value1%2Cwith%20comma)"; + const expectedResult = { + selector1: { + key1: "value1,with comma", + }, + }; + const result = parseBracketedKeyValueHash(input); + expect(result.selector1.key1).to.equal(expectedResult.selector1.key1); + }); + + it("should handle input without a leading hash", () => { + const input = "selector1(key1=value1)"; + const expectedResult = { + selector1: { + key1: "value1", + }, + }; + expect(parseBracketedKeyValueHash(input)).to.deep.equal(expectedResult); + }); + + it("should return an empty object for invalid input", () => { + const input = "#selector1(key1=value1,key2"; + const expectedResult = {}; + expect(parseBracketedKeyValueHash(input)).to.deep.equal(expectedResult); + }); + + it('should return an empty object for an empty input string', () => { + const hashString = ''; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({}); + }); + + it('should return an empty object for an invalid input string', () => { + const hashString = '#invalid'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({}); + }); + + it('should parse a simple input string with one selector and one key-value pair', () => { + const hashString = '#selector(key=value)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({selector: {key: 'value'}}); + }); + + it('should parse an input string with multiple selectors and key-value pairs', () => { + const hashString = '#selector1(key1=value1);selector2(key2=value2)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({selector1: {key1: 'value1'}, selector2: {key2: 'value2'}}); + }); + + it('should handle empty values', () => { + const hashString = '#selector(key1=,key2=)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({selector: {key1: '', key2: ''}}); + }); + + it('should handle percent-encoded values', () => { + const hashString = '#selector(key1=value%201,key2=value%2C2)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({selector: {key1: 'value 1', key2: 'value,2'}}); + }); + + it('should handle double-quoted values with commas', () => { + const hashString = '#selector(key1="value,1",key2="value,2")'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({selector: {key1: 'value,1', key2: 'value,2'}}); + }); + + it('should ignore leading hash symbol (#)', () => { + const hashString = 'selector(key=value)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({selector: {key: 'value'}}); + }); + + it('should ignore leading and trailing white space', () => { + const hashString = ' #selector(key=value) '; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({selector: {key: 'value'}}); + }); + + it('should return an empty object if the input string ends prematurely', () => { + const hashString = '#selector(key=value'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({}); + }); + + it('should return an empty object if a selector is missing', () => { + const hashString = '#(key=value)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({}); + }); + + it('should return an empty object if a key is missing', () => { + const hashString = '#selector(=value)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({}); + }); + + it('should return an empty object ifa value is missing', () => { + const hashString = '#selector(key=)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({ + selector: { + key: '', + }, + }); + }); + + it('should return an empty object if there is no closing parenthesis for a selector', () => { + const hashString = '#selector(key=value;'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({}); + }); + + it('should return an empty object if there is no semicolon after a selector', () => { + const hashString = '#selector(key=value)selector2(key2=value2)'; + const result = parseBracketedKeyValueHash(hashString); + expect(result).to.deep.equal({ + selector: { + key: 'value', + }, + selector2: { + key2: 'value2', + }, + }); + }); + + describe('createBracketedKeyValueHash', () => { + it('should return an hash string for a simple object', () => { + const input = { + '.example': { + 'color': 'red', + 'font-size': '14px' + }, + '.other': { + 'background': 'blue' + } + }; + + const result = createBracketedKeyValueHash(input); + expect(result).to.deep.equal("#.example(color=red,font-size=14px);.other(background=blue)"); + }); + + it('should return a url-encoded hash string for a simple object', () => { + const input = { + '.example': { + 'color': 'r"ed', + 'font-size': '14px' + }, + '.other': { + 'background': 'blue' + } + }; + + const result = createBracketedKeyValueHash(input, true); + expect(result).to.deep.equal("#.example(color=r%22ed,font-size=14px);.other(background=blue)"); + }); + + it('should return an empty string for an empty object', () => { + const input = {}; + const result = createBracketedKeyValueHash(input,false); + expect(result).to.deep.equal(""); + }); + + it('should return an empty string for an empty object', () => { + const input = {}; + const result = createBracketedKeyValueHash(input,false); + expect(result).to.deep.equal(""); + }); + + }); + + +}); -- GitLab