diff --git a/.gitignore b/.gitignore index 5673960..10d5074 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ coverage/ debug.log +dist yarn-error.log .DS_Store .eslintcache diff --git a/.vscode/settings.json b/.vscode/settings.json index e7935e0..a09ecf6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,6 @@ { "language": "html", "autoFix": true } ], "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" } } diff --git a/package.json b/package.json index 7fa956d..5a512f7 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,12 @@ "demo/*.json", "cosmoz-*.js", "test/data/", - "examples" + "examples", + "dist/*" ], "scripts": { - "lint": "eslint --cache --ext .js,.html .", + "lint": "tsc && eslint --cache --ext .js,.html .", + "build": "tsc -p tsconfig.build.json", "start": "wds", "test": "wtr --coverage", "test:watch": "wtr --watch", @@ -47,6 +49,10 @@ "publishConfig": { "access": "public" }, + "exports": { + "./cosmoz-tree": "./dist/cosmoz-tree.js", + "./cosmoz-default-tree": "./dist/cosmoz-default-tree.js" + }, "commitlint": { "extends": [ "@commitlint/config-conventional" diff --git a/src/cosmoz-default-tree.ts b/src/cosmoz-default-tree.ts new file mode 100644 index 0000000..069080d --- /dev/null +++ b/src/cosmoz-default-tree.ts @@ -0,0 +1,3 @@ +import { Tree } from './cosmoz-tree'; + +export { Tree as DefaultTree }; diff --git a/src/cosmoz-tree.ts b/src/cosmoz-tree.ts new file mode 100644 index 0000000..508c13d --- /dev/null +++ b/src/cosmoz-tree.ts @@ -0,0 +1,393 @@ +/** +Navigator through object with treelike datastructure and default settings. + +@demo demo/index.html +*/ + +export type TreeOptions = { + childProperty?: string; + searchProperty?: string; + pathLocatorSeparator?: string; + pathStringSeparator?: string; +}; + +export class Tree { + _treeData: object; + + _roots: object; + + childProperty: string; + + searchProperty: string; + + pathLocatorSeparator: string; + + pathStringSeparator: string; + + /** + * @param {Object} treeData (The tree object.) + * @param {Object} options (Tree options.) + * @param {String} options.childProperty ["children"] (The name of the property a search should be based on. e.g. "name") + * @param {String} options.propertyName ["name"] (The name of the property a search should be based on. e.g. "name") + * @param {String} options.pathStringSeparator ["/"] (The string the path should get separated with.) + * @param {String} options.pathLocatorSeparator ["."] (The string which separates the path segments of a path locator.) + */ + constructor(treeData: object, options: TreeOptions = {}) { + this._treeData = treeData; + this._roots = Object.values(treeData); + + this.pathLocatorSeparator = options.pathLocatorSeparator || '.'; + this.pathStringSeparator = options.pathStringSeparator || '/'; + this.childProperty = options.childProperty || 'children'; + this.searchProperty = options.searchProperty || 'name'; + } + + static _sortPathNodes(a, b) { + const undefCounter = (item) => item === undefined, + defCounter = (item) => item, + aUndefCount = a.filter(undefCounter).length, + bUndefCount = b.filter(undefCounter).length, + aDefCount = a.filter(defCounter).length, + bDefCount = b.filter(defCounter).length; + + if (aUndefCount < bUndefCount) { + return -1; + } + if (aUndefCount > bUndefCount || aDefCount < bDefCount) { + return 1; + } + if (aDefCount > bDefCount) { + return -1; + } + return 0; + } + + /** + * Searches a (multi root) node and matches nodes based on a property and a value. + * @returns {Object} - The first found node. + * @param {String} propertyValue (The value of the property the match should be based on. e.g. "Peter") + * @param {String} propertyName (The name of the property the match should be based on. e.g. "name") + * @param {Array} nodes [this._roots] (The objects the search should be based on.) + */ + getNodeByProperty( + propertyValue, + propertyName = this.searchProperty, + nodes = this._roots, + ) { + if (propertyValue === undefined) { + return; + } + + return this.findNode(propertyValue, propertyName, nodes); + } + + /** + * Searches a (multi root) node and matches nodes based on a property and a value. + * @returns {Array} - All found nodes. + * @param {String} propertyValue (The value of the property the match should be based on. e.g. "Peter") + * @param {Object} nodes [this._treeData] (The nodes the search should be based on.) + * @param {Boolean} exact [true] (If the search should be executed exact or flaw. true wouldn't match "Pet") + * @param {String} propertyName [this.searchProperty] (The name of the property the match should be based on. e.g. "name") + */ + searchNodes(propertyValue, nodes, exact, propertyName = this.searchProperty) { + const options = { + propertyName, + exact: exact !== undefined ? exact : true, + firstHitOnly: false, + }; + return this._searchNodes(propertyValue, options, nodes); + } + + /** + * Searches a (multi root) node and matches nodes based on a property and a value. + * @returns {Object} - The first found node. + * @param {String} propertyValue (The value of the property the match should be based on. e.g. "Peter") + * @param {String} propertyName [this.searchProperty] (The name of the property the match should be based on. e.g. "name") + * @param {Object} nodes [this._treeData] (The nodes the search should be based on.) + */ + findNode(propertyValue, propertyName = this.searchProperty, nodes) { + const options = { + propertyName, + exact: true, + firstHitOnly: true, + }; + return this._searchNodes(propertyValue, options, nodes).shift(); + } + + /** + * Searches a (multi root) node and matches nodes based on a property and a value. + * @returns {Array} - The found node(s). + * @param {String} propertyValue (The value of the property the match should be based on. e.g. "Peter") + * @param {Object} options (Matching options) + * @param {String} options.propertyName (The name of the property the match should be based on. e.g. "name") + * @param {Boolean} options.exact [false] (If the search should be executed exact or fuzzy. true wouldn't match "Pet") + * @param {Boolean} options.firstHitOnly [false] (If the search should only return the first found node.) + * @param {Object} nodes [this._roots] (The nodes the search should be based on.) + */ + _searchNodes(propertyValue, options, nodes = this._roots) { + const results = []; + + for (const node of nodes) { + const res = this.search(node, propertyValue, options); + if (options.firstHitOnly && res.length > 0) { + return res; + } + results.push(...res); + } + + return results; + } + + /** + * Returns the node of a given path. + * @returns {Object} The node object + * @param {String} pathLocator (The string which describes the path. e.g. "1.2.9") + * @param {Object} nodeObj [this._treeData] (The object the search should be based on.) + * @param {String} pathLocatorSeparator [this.pathLocatorSeparator] (The string which separates the path. e.g ".") + */ + getNodeByPathLocator( + pathLocator, + nodeObj = this._treeData, + pathLocatorSeparator = this.pathLocatorSeparator, + ) { + if (!pathLocator) { + return this._roots; + } + + const pathNodes = this.getPathNodes( + pathLocator, + nodeObj, + pathLocatorSeparator, + ); + return pathNodes && pathNodes.pop(); + } + + /** + * Returns the nodes on a given path. + * A valid path 1.2.3 should return the items [1, 2, 3] + * - path 1.2.3.3 should return [1, 2, 3, undefined] + * - path 0.1.2.3 should return [1, 2, 3] + * - path 0.1.5.3 should return [1, undefined, undefined] + * @returns {Array} The node array + * @param {String} pathLocator (The string which describes the path. e.g. "1.2.9") + * @param {Object} nodeObj [this._treeData] (The object the search should be based on.) + * @param {String} pathLocatorSeparator [this.pathLocatorSeparator] (The string which separates the path.) + */ + getPathNodes( + pathLocator, + nodeObj = this._treeData, + pathLocatorSeparator = this.pathLocatorSeparator, + ) { + if (!pathLocator) { + return nodeObj; + } + + return Object.keys(nodeObj) + .map((key) => { + const subTree = {}; + subTree[key] = nodeObj[key]; + return this._getPathNodes(pathLocator, subTree, pathLocatorSeparator); + }) + .filter((item) => { + return item && item.length > 0; + }) + .sort(this.constructor._sortPathNodes)[0]; + } + + _getPathNodes( + pathLocator, + nodeObj = this._treeData, + pathLocatorSeparator = this.pathLocatorSeparator, + ) { + const path = pathLocator.split(pathLocatorSeparator), + nodes = this._pathToNodes(path, nodeObj, pathLocatorSeparator); + + // Filter out undefined items of the start + while (nodes.length > 0 && nodes[0] === undefined) { + nodes.shift(); + } + + return nodes; + } + + _pathToNodes(path, nodes, separator) { + let pathSegment = nodes; + return path.map((nodeKey, i) => { + // Get the nodes on the path + if (!pathSegment) { + return false; + } + const node = + pathSegment[nodeKey] ?? + pathSegment[path.slice(0, i + 1).join(separator)]; + if (node) { + pathSegment = node[this.childProperty]; + } + return node; + }); + } + + /** + * Returns a string which describes the path of a node (found by its path locator). + * @returns {String} e.g. home/computer/desktop + * @param {String} pathLocator (The string which describes the path. e.g. "1.2.9") + * @param {String} pathProperty (The property of a node on which the path should be build on. e.g "location" with node = {"location": "home", ..}) + * @param {String} pathStringSeparator [this.pathStringSeparator] (The string the path should get separated with.) + * @param {String} pathLocatorSeparator [this.pathLocatorSeparator] (The string which separates the path segments of pathLocator.) + */ + getPathString( + pathLocator, + pathProperty = this.searchProperty, + pathStringSeparator = this.pathStringSeparator, + pathLocatorSeparator = this.pathLocatorSeparator, + ) { + const pathNodes = this.getPathNodes( + pathLocator, + this._treeData, + pathLocatorSeparator, + ); + + if (!Array.isArray(pathNodes)) { + return; + } + + return pathNodes + .filter((node) => node != null) + .map((node) => node[pathProperty]) + .join(pathStringSeparator); + } + + /** + * Returns a string which describes the path of a node (found by a node's property and value). + * @returns {String} e.g. home/computer/desktop + * @param {String} propertyValue (The value of the property the match should be based on. e.g. "Peter") + * @param {String} propertyName (The name of the property the match should be based on. e.g. "name") + * @param {String} pathProperty (The property of a node on which the path should be build on. e.g "location" if node = {"location": "home"}) + * @param {String} pathStringSeparator [this.pathStringSeparator] (The string the path should get separated with.) + * @param {String} pathLocatorSeparator [this.pathLocatorSeparator] (The string which separates the path. e.g ".") + */ + getPathStringByProperty( + propertyValue, + propertyName = this.searchProperty, + pathProperty = this.searchProperty, + pathStringSeparator = this.pathStringSeparator, + pathLocatorSeparator = this.pathLocatorSeparator, + ) { + if (propertyValue === undefined) { + return; + } + + if (propertyName === 'pathLocator') { + return this.getPathString( + propertyValue, + pathProperty, + pathStringSeparator, + pathLocatorSeparator, + ); + } + + const node = this.getNodeByProperty(propertyValue, propertyName); + + if (node) { + const path = node.pathLocator || node.path; + return this.getPathString(path, pathProperty, pathStringSeparator); + } + } + + /** + * Returns an Object or an Array representing the children of a node. + * @param {Object} node The object to return children from + * @returns {Object|Array} The node's children + */ + getChildren(node) { + if (!node || !node[this.childProperty]) { + return []; + } + return Object.values(node[this.childProperty]); + } + + /** + * Returns true if a node has children. + * @param {Object} node The object to get children from + * @returns {Boolean} True if node has children + */ + hasChildren(node) { + if (!node) { + return false; + } + const childMap = node[this.childProperty]; + if (!childMap) { + return false; + } + // eslint-disable-next-line guard-for-in + for (const key in childMap) { + return true; + } + return false; + } + + /** + * Returns the property of a Node based on a given property name. + * @param {Object} node The object to get property from + * @param {String} propertyName The name of property + * @returns {*} The value of the property + */ + getProperty(node, propertyName) { + if (!node || !propertyName) { + return; + } + return node[propertyName]; + } + + /** + * Checks if a node matches the search criteria. + * @returns {Boolean} True if node matches + * @param {node} node (The node the check should be based on.) + * @param {String} propertyValue (The value of the property the match should be based on. e.g. "Peter") + * @param {Object} options (Comparison options) + * @param {String} options.propertyName (The name of the property the match should be based on. e.g. "name") + * @param {Boolean} options.exact [false] (If the search should be executed exact or fuzzy. true wouldn't match "Pet") + */ + nodeConformsSearch(node, propertyValue, options) { + const property = options ? node[options.propertyName] : undefined; + + if (!property) { + // eslint-disable-next-line no-console + console.error('options.propertyName needs to be specified.'); + return; + } + + if (options.exact) { + return property === propertyValue; + } + return property.toLowerCase().indexOf(propertyValue.toLowerCase()) > -1; + } + + /** + * Searches a (multi root) node and matches nodes based on a property and a value. + * @returns {Array} The nodes found + * @param {node} node The node to search in. + * @param {String} propertyValue (The value of the property the match should be based on. e.g. "Peter") + * @param {Object} options (Search options) + * @param {String} options.propertyName (The name of the property the match should be based on. e.g. "name") + * @param {Boolean} options.exact [false] (If false, the propertyValue is matched fuzzy) + * @param {Array} results (The array search results get added to.) Default: [] + */ + search(node, propertyValue, options, results = []) { + const nodeConforms = this.nodeConformsSearch(node, propertyValue, options), + children = this.getChildren(node); + + if (nodeConforms) { + results.push(node); + } + + for (const child of children) { + const result = this.search(child, propertyValue, options, results); + if (!Array.isArray(result)) { + return [result]; + } + } + + return results; + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..640f9ba --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "noEmit": false + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..754a1b9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "target": "esnext", + "allowJs": true + } +}