Skip to content
Snippets Groups Projects
Verified Commit ebf28ffb authored by Volker Schukai's avatar Volker Schukai :alien:
Browse files

feat: new functions parseBracketedKeyValueHash and createBracketedKeyValueHash

parent 4f42fd84
No related branches found
No related tags found
No related merge requests found
/**
* 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
/**
* 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;
}
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;
}
// 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("");
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment