├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── badges ├── badge-branches.svg ├── badge-functions.svg ├── badge-lines.svg └── badge-statements.svg ├── package-lock.json ├── package.json ├── profile.js ├── rollup.config.js ├── src ├── JSONHeroSearch.ts ├── fuzzyScoring.ts ├── hash.ts ├── index.ts ├── scoring.ts ├── search.ts └── strings.ts ├── tests ├── fixtures │ └── airtable.json ├── jsonHeroSearch.test.ts ├── scoring.test.ts ├── searching.test.ts ├── setup.js └── utils │ ├── getAllPaths.ts │ └── jsonHeroAccessor.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "rules": { 12 | "prettier/prettier": 2 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: "16.x" 13 | registry-url: "https://registry.npmjs.org" 14 | - run: npm ci 15 | - run: npm publish 16 | env: 17 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v2 9 | with: 10 | node-version: "16.x" 11 | registry-url: "https://registry.npmjs.org" 12 | - run: npm ci 13 | - run: npm test 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore built ts files 38 | lib/**/* 39 | 40 | # ignore yarn.lock 41 | yarn.lock 42 | /tests/test.sqlite 43 | lib -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triggerdotdev/fuzzy-json-search/507971d425742cc0ecda8bc69912873db2e66d4f/.npmignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 100 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Jest All Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "--inspect-brk", 13 | "${workspaceRoot}/node_modules/.bin/jest", 14 | "--runInBand" 15 | ], 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen" 18 | }, 19 | { 20 | "name": "Debug Jest Test File", 21 | "type": "node", 22 | "request": "launch", 23 | "runtimeArgs": [ 24 | "--inspect-brk", 25 | "${workspaceRoot}/node_modules/.bin/jest", 26 | "--runInBand" 27 | ], 28 | "args": ["${fileBasename}", "--no-cache"], 29 | "console": "integratedTerminal", 30 | "internalConsoleOptions": "neverOpen" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Eric Allam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fuzzy JSON Search 2 | 3 | > VSCode style fuzzy search for JSON documents 4 | 5 | 6 | 7 | 8 | 9 | ## 🚀 Features 10 | 11 | - Use VSCode style fuzzy search on a JSON document 12 | - Searches through key names, path, raw values and formatted values 13 | 14 | ## 💻 Usage 15 | 16 | Install Fuzzy JSON Search 17 | 18 | ```bash 19 | $ npm install --save @jsonhero/fuzzy-json-search 20 | ``` 21 | 22 | The simplest way to search is to create an instance of `JSONHeroSearch` and pass it a JSON object: 23 | 24 | ```typescript 25 | const response = await fetch("https://jsonplaceholder.typicode.com/todos"); 26 | const json = await response.json(); 27 | 28 | const searcher = new JSONHeroSearch(json); 29 | 30 | const results = searcher.search("user"); 31 | ``` 32 | 33 | ## API 34 | 35 | ### `JSONHeroSearch.search(query: string)` 36 | 37 | Performs a fuzzy search against the entire document, ordering by score. Will only return results that score more than 0. 38 | 39 | #### Returns `Array>>` 40 | 41 | `SearchResult` has the following properties: 42 | 43 | ##### `item` is a `string` representing the path to the key 44 | 45 | ##### `score` is an `ItemScore` 46 | 47 | ##### `ItemScore` has the following properties 48 | 49 | ##### `score` is a number, the higher the score the better a match 50 | 51 | ##### `labelMatch` is an array of `Match` objects 52 | 53 | ##### `descriptionMatch` is an array of `Match` objects 54 | 55 | ##### `rawValueMatch` is an array of `Match` objects 56 | 57 | ##### `formattedValueMatch` is an array of `Match` objects 58 | 59 | ##### `Match` is type `{ start: number; end: number }` 60 | -------------------------------------------------------------------------------- /badges/badge-branches.svg: -------------------------------------------------------------------------------- 1 | Coverage:branches: 94.11%Coverage:branches94.11% -------------------------------------------------------------------------------- /badges/badge-functions.svg: -------------------------------------------------------------------------------- 1 | Coverage:functions: 100%Coverage:functions100% -------------------------------------------------------------------------------- /badges/badge-lines.svg: -------------------------------------------------------------------------------- 1 | Coverage:lines: 100%Coverage:lines100% -------------------------------------------------------------------------------- /badges/badge-statements.svg: -------------------------------------------------------------------------------- 1 | Coverage:statements: 100%Coverage:statements100% -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jsonhero/fuzzy-json-search", 3 | "version": "0.2.2", 4 | "description": "VSCode style fuzzy search for JSON documents", 5 | "homepage": "https://github.com/jsonhero-io/fuzzy-json-search", 6 | "bugs": { 7 | "url": "https://github.com/jsonhero-io/fuzzy-json-search/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/jsonhero-io/fuzzy-json-search.git" 12 | }, 13 | "exports": "./lib/index.js", 14 | "types": "lib/index.d.ts", 15 | "main": "./lib/index.js", 16 | "type": "commonjs", 17 | "module": "./lib/index.mjs", 18 | "files": [ 19 | "/lib" 20 | ], 21 | "publishConfig": { 22 | "access": "public" 23 | }, 24 | "scripts": { 25 | "clean": "rimraf lib", 26 | "check-types": "tsc --noEmit", 27 | "test": "jest", 28 | "test:cov": "jest --coverage", 29 | "test:watch": "jest --watchAll", 30 | "test:badges": "npm t && jest-coverage-badges --output ./badges", 31 | "build": "rollup -c", 32 | "prepublishOnly": "npm run clean && npm run check-types && npm run format:check && npm run lint && npm test && npm run build", 33 | "lint": "eslint . --ext .ts", 34 | "lint-and-fix": "eslint . --ext .ts --fix", 35 | "format": "prettier --config .prettierrc 'src/**/*.ts' --write && prettier --config .prettierrc 'tests/**/*.ts' --write", 36 | "format:check": "prettier --config .prettierrc --list-different 'src/**/*.ts'" 37 | }, 38 | "engines": { 39 | "node": "16" 40 | }, 41 | "keywords": [ 42 | "search", 43 | "json", 44 | "fuzzy", 45 | "vscode" 46 | ], 47 | "author": "Eric Allam", 48 | "license": "MIT", 49 | "devDependencies": { 50 | "@jsonhero/path": "^1.0.19", 51 | "@jsonhero/json-infer-types": "^1.2.9", 52 | "@rollup/plugin-node-resolve": "^13.1.2", 53 | "@types/jest": "^27.0.2", 54 | "@types/lru-cache": "^7.6.1", 55 | "@types/node": "^16.11.7", 56 | "@typescript-eslint/eslint-plugin": "^5.8.1", 57 | "@typescript-eslint/parser": "^5.8.1", 58 | "eslint": "^8.5.0", 59 | "eslint-config-prettier": "^8.3.0", 60 | "eslint-plugin-prettier": "^4.0.0", 61 | "jest": "^27.3.1", 62 | "prettier": "^2.5.1", 63 | "rimraf": "^3.0.2", 64 | "rollup": "^2.62.0", 65 | "rollup-plugin-typescript2": "^0.31.1", 66 | "ts-jest": "^27.0.7", 67 | "ts-node": "^10.4.0", 68 | "typescript": "^4.4.4" 69 | }, 70 | "jest": { 71 | "preset": "ts-jest", 72 | "testEnvironment": "node", 73 | "coverageReporters": [ 74 | "json-summary", 75 | "text", 76 | "lcov" 77 | ], 78 | "globalSetup": "./tests/setup.js" 79 | }, 80 | "husky": { 81 | "hooks": { 82 | "pre-commit": "npm run prettier-format && npm run lint" 83 | } 84 | }, 85 | "dependencies": { 86 | "lru-cache": "^7.8.1" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /profile.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable no-undef */ 3 | const { JSONHeroSearch } = require("./lib"); 4 | 5 | const json = { 6 | records: [ 7 | { 8 | id: "rec3SDRbI5izJ0ENy", 9 | fields: { 10 | Link: "www.examplelink.com", 11 | Name: "Ikrore chair", 12 | Settings: ["Office", "Bedroom", "Living room"], 13 | Vendor: ["reczC9ifQTdJpMZcx"], 14 | Color: ["Grey", "Green", "Red", "White", "Blue purple"], 15 | Designer: ["recJ76rS7fEJi03wW"], 16 | Type: "Chairs", 17 | Images: [ 18 | { 19 | id: "atten0ycxONEmeKfu", 20 | width: 501, 21 | height: 750, 22 | url: "https://dl.airtable.com/.attachments/e13d90aafb01450314538eee5398abb3/ea5e6e6f/pexels-photo-1166406.jpegautocompresscstinysrgbh750w1260", 23 | filename: "pexels-photo-1166406.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260", 24 | size: 33496, 25 | type: "image/jpeg", 26 | thumbnails: { 27 | small: { 28 | url: "https://dl.airtable.com/.attachmentThumbnails/ff3db1021522f6100afa7e09ab42b187/9bc0dc81", 29 | width: 24, 30 | height: 36, 31 | }, 32 | large: { 33 | url: "https://dl.airtable.com/.attachmentThumbnails/15421f668579a7d75c506253b61668d6/f7c14834", 34 | width: 501, 35 | height: 750, 36 | }, 37 | full: { 38 | url: "https://dl.airtable.com/.attachmentThumbnails/bd297cad0f2acb7da5d63e0692934def/3053bea3", 39 | width: 3000, 40 | height: 3000, 41 | }, 42 | }, 43 | }, 44 | ], 45 | Description: 46 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 47 | Materials: ["Tech suede", "Light wood"], 48 | "Size (WxLxH)": "40x32x19", 49 | "Unit cost": 1300.5, 50 | "Total units sold": 0, 51 | "Gross sales": 0, 52 | }, 53 | createdTime: "2015-01-27T20:16:05.000Z", 54 | }, 55 | { 56 | id: "rec4gR4daG7FLbTss", 57 | fields: { 58 | Link: "www.examplelink.com", 59 | Name: "Angular pendant", 60 | Settings: ["Office"], 61 | Vendor: ["reczC9ifQTdJpMZcx"], 62 | Color: ["Silver", "Black", "White", "Gold"], 63 | Designer: ["recoh9S9UjHVUpcPy"], 64 | "In stock": true, 65 | Type: "Lighting", 66 | Orders: ["recspa0dTuVfr5Tji"], 67 | Images: [ 68 | { 69 | id: "attViFaKwjE6WJ3iD", 70 | width: 1000, 71 | height: 1500, 72 | url: "https://dl.airtable.com/.attachments/ce5d081b96ad1d4ef7aa3003c77fb761/4e9b68ae/photo-1546902172-146006dcd1e6ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 73 | filename: 74 | "photo-1546902172-146006dcd1e6?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 75 | size: 163784, 76 | type: "image/jpeg", 77 | thumbnails: { 78 | small: { 79 | url: "https://dl.airtable.com/.attachmentThumbnails/ffa7089696c170c6be567d5f34b4ed66/e1046fbc", 80 | width: 24, 81 | height: 36, 82 | }, 83 | large: { 84 | url: "https://dl.airtable.com/.attachmentThumbnails/e66162154bfa7eacd377d40266f57316/39fb0eac", 85 | width: 512, 86 | height: 768, 87 | }, 88 | full: { 89 | url: "https://dl.airtable.com/.attachmentThumbnails/7070d3cb16ad9d18e4fa5bbedb4e740b/460fd6c4", 90 | width: 3000, 91 | height: 3000, 92 | }, 93 | }, 94 | }, 95 | ], 96 | Description: 97 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 98 | Materials: ["Steel"], 99 | "Size (WxLxH)": "7.5 x 12.75, 10.5 x 17.5 ", 100 | "Unit cost": 295, 101 | "Total units sold": 2, 102 | "Gross sales": 590, 103 | }, 104 | createdTime: "2015-01-27T19:14:59.000Z", 105 | }, 106 | { 107 | id: "rec4rIuzOPQA07c3M", 108 | fields: { 109 | Link: "www.examplelink.com", 110 | Name: "Madrid chair", 111 | Settings: ["Living room", "Office"], 112 | Vendor: ["reczC9ifQTdJpMZcx"], 113 | Color: ["White", "Brown", "Black"], 114 | Designer: ["recqx2njQY1QqkcaV"], 115 | "In stock": true, 116 | Type: "Chairs", 117 | Orders: ["rec0jJArKIPxTddSX", "rec3mEIxLONBSab4Y"], 118 | Images: [ 119 | { 120 | id: "attYAf0fLp3H3OdGk", 121 | width: 1000, 122 | height: 477, 123 | url: "https://dl.airtable.com/.attachments/c717b870174222c61991d81d32e6faa4/1ef6556a/photo-1505843490538-5133c6c7d0e1ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 124 | filename: 125 | "photo-1505843490538-5133c6c7d0e1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 126 | size: 17498, 127 | type: "image/jpeg", 128 | thumbnails: { 129 | small: { 130 | url: "https://dl.airtable.com/.attachmentThumbnails/c3e8f6f2189b0d9eb14cb58b9c653f42/3b76d95a", 131 | width: 75, 132 | height: 36, 133 | }, 134 | large: { 135 | url: "https://dl.airtable.com/.attachmentThumbnails/e222fd421eddb24f9b5171a25adaa9ec/3cf86de6", 136 | width: 1000, 137 | height: 477, 138 | }, 139 | full: { 140 | url: "https://dl.airtable.com/.attachmentThumbnails/4cae754b4adc96820e98a79ca8ebdcbd/09040841", 141 | width: 3000, 142 | height: 3000, 143 | }, 144 | }, 145 | }, 146 | ], 147 | Description: 148 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 149 | Materials: ["Light wood", "Metal"], 150 | "Size (WxLxH)": "3x1x5", 151 | "Unit cost": 5429, 152 | "Total units sold": 36, 153 | "Gross sales": 195444, 154 | }, 155 | createdTime: "2014-09-24T05:48:20.000Z", 156 | }, 157 | { 158 | id: "recBFo9FzF6WKrgN6", 159 | fields: { 160 | Link: "www.examplelink.com", 161 | Name: "Drux table lamp", 162 | Settings: ["Living room", "Office", "Bedroom"], 163 | Vendor: ["rec2nan8zh3q75IlN"], 164 | Color: ["Beige"], 165 | "In stock": true, 166 | Type: "Lighting", 167 | Images: [ 168 | { 169 | id: "attp536Smad59jyla", 170 | width: 1000, 171 | height: 667, 172 | url: "https://dl.airtable.com/.attachments/2286e1e54efa0c4c5fd7b55ece6ca823/2c6d2881/photo-1517991104123-1d56a6e81ed9ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 173 | filename: 174 | "photo-1517991104123-1d56a6e81ed9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 175 | size: 68041, 176 | type: "image/jpeg", 177 | thumbnails: { 178 | small: { 179 | url: "https://dl.airtable.com/.attachmentThumbnails/ff54f1100a889a34635bc9aaf3b5c20d/cd82feb5", 180 | width: 54, 181 | height: 36, 182 | }, 183 | large: { 184 | url: "https://dl.airtable.com/.attachmentThumbnails/496ebcea5a4c00cb2fc1a2b4fc9bed65/48d4c29b", 185 | width: 768, 186 | height: 512, 187 | }, 188 | full: { 189 | url: "https://dl.airtable.com/.attachmentThumbnails/fbd305226389dc0490692708407bfb56/a01ff950", 190 | width: 3000, 191 | height: 3000, 192 | }, 193 | }, 194 | }, 195 | ], 196 | Description: 197 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 198 | Materials: ["Linen shade", "Light wood"], 199 | "Size (WxLxH)": "14 x 19", 200 | "Unit cost": 249, 201 | "Total units sold": 0, 202 | "Gross sales": 0, 203 | }, 204 | createdTime: "2015-01-27T19:32:12.000Z", 205 | }, 206 | { 207 | id: "recCGiMUCCGp68MCK", 208 | fields: { 209 | Link: "www.examplelink.com", 210 | Name: "Twist side table", 211 | Settings: ["Living room"], 212 | Vendor: ["rec2nan8zh3q75IlN"], 213 | Color: ["Beige", "Grey"], 214 | Designer: ["recfb5rmXwdm6LNoo"], 215 | Type: "Tables", 216 | Orders: ["recNvcuOyJ1FymJ6O"], 217 | Images: [ 218 | { 219 | id: "attBNs3uKN8EeLLwd", 220 | width: 1000, 221 | height: 667, 222 | url: "https://dl.airtable.com/.attachments/cfa21745dc2a50d1c62998834e4b4ee5/a843884d/photo-1507392671925-cbcf789b478dixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 223 | filename: 224 | "photo-1507392671925-cbcf789b478d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 225 | size: 73008, 226 | type: "image/jpeg", 227 | thumbnails: { 228 | small: { 229 | url: "https://dl.airtable.com/.attachmentThumbnails/27d6af299bd87005498cf836c8dd2d76/d41b63ac", 230 | width: 54, 231 | height: 36, 232 | }, 233 | large: { 234 | url: "https://dl.airtable.com/.attachmentThumbnails/33e0f6df42aacd24701c8c265b268dad/1509733e", 235 | width: 768, 236 | height: 512, 237 | }, 238 | full: { 239 | url: "https://dl.airtable.com/.attachmentThumbnails/c1b60459eb765235d00b4a54110c84ba/ef82545e", 240 | width: 3000, 241 | height: 3000, 242 | }, 243 | }, 244 | }, 245 | ], 246 | Description: 247 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 248 | Materials: ["Light wood", "Steel"], 249 | "Size (WxLxH)": "20x20", 250 | "Unit cost": 1190, 251 | "Total units sold": 6, 252 | "Gross sales": 7140, 253 | }, 254 | createdTime: "2015-01-27T21:08:17.000Z", 255 | }, 256 | { 257 | id: "recFeWSbSQuoPAuOD", 258 | fields: { 259 | Link: "www.examplelink.com", 260 | Name: "Compel bookcase", 261 | Settings: ["Living room", "Office"], 262 | Vendor: ["rec2nan8zh3q75IlN"], 263 | Color: ["Black"], 264 | Designer: ["receFxQ9bUODXrYwv"], 265 | "In stock": true, 266 | Type: "Bookshelves", 267 | Orders: ["reciqXiKe6VxKhXQt"], 268 | Images: [ 269 | { 270 | id: "attumnpGmMgmf04Uz", 271 | width: 563, 272 | height: 750, 273 | url: "https://dl.airtable.com/.attachments/345f743bec40372349993d2325ea8b0c/b97de268/pexels-photo-2067569.jpegautocompresscstinysrgbh750w1260", 274 | filename: "pexels-photo-2067569.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260", 275 | size: 77609, 276 | type: "image/jpeg", 277 | thumbnails: { 278 | small: { 279 | url: "https://dl.airtable.com/.attachmentThumbnails/ad5bee131f93f45cf9a748348f1a1954/dbaeac82", 280 | width: 27, 281 | height: 36, 282 | }, 283 | large: { 284 | url: "https://dl.airtable.com/.attachmentThumbnails/ae1c61f4dd2dd705a1bccb6c311d321e/d6da82cf", 285 | width: 512, 286 | height: 682, 287 | }, 288 | full: { 289 | url: "https://dl.airtable.com/.attachmentThumbnails/b52e81302847db42be4da56316ae6538/914b6689", 290 | width: 3000, 291 | height: 3000, 292 | }, 293 | }, 294 | }, 295 | ], 296 | Description: 297 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 298 | Materials: ["Dark wood"], 299 | "Size (WxLxH)": "14x16 and 14x79.5", 300 | "Unit cost": 218, 301 | "Total units sold": 2, 302 | "Gross sales": 436, 303 | }, 304 | createdTime: "2015-01-27T20:06:03.000Z", 305 | }, 306 | { 307 | id: "recKVCoiSBG9B3RU2", 308 | fields: { 309 | Notes: 310 | 'Going to use this chair in the office design for Apse Realty @Lane Bergman ', 311 | Link: "www.examplelink.com", 312 | Name: "Nebula chair", 313 | Settings: ["Office", "Bedroom"], 314 | Vendor: ["reczC9ifQTdJpMZcx"], 315 | Color: ["White", "Brown", "Cherry", "Black"], 316 | Designer: ["recnONq5IbG8RyBLp"], 317 | Type: "Chairs", 318 | Images: [ 319 | { 320 | id: "attmbNMd04nNviHUV", 321 | width: 1000, 322 | height: 1500, 323 | url: "https://dl.airtable.com/.attachments/33cec7be64f0cab3b249dc0d7e587ea7/9a77c2c6/photo-1519947486511-46149fa0a254ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 324 | filename: 325 | "photo-1519947486511-46149fa0a254?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 326 | size: 159823, 327 | type: "image/jpeg", 328 | thumbnails: { 329 | small: { 330 | url: "https://dl.airtable.com/.attachmentThumbnails/b2b274ad3ca86bc6c4f0c7b70ad16d74/7d0c5454", 331 | width: 24, 332 | height: 36, 333 | }, 334 | large: { 335 | url: "https://dl.airtable.com/.attachmentThumbnails/1c8dddc86d0ce3283018e7246cc95004/a3dd69dc", 336 | width: 512, 337 | height: 768, 338 | }, 339 | full: { 340 | url: "https://dl.airtable.com/.attachmentThumbnails/c196c8eddbd4a1abc182819e65db38ce/04705788", 341 | width: 3000, 342 | height: 3000, 343 | }, 344 | }, 345 | }, 346 | ], 347 | Description: 348 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 349 | Materials: ["Lacquered ash", "Light wood"], 350 | "Size (WxLxH)": 'H 31.75" W 18" D 19.75" Seat H 17.25"', 351 | "Unit cost": 382.25, 352 | "Total units sold": 0, 353 | "Gross sales": 0, 354 | }, 355 | createdTime: "2015-01-27T19:16:56.000Z", 356 | }, 357 | { 358 | id: "recVSt2jMwxnCywtw", 359 | fields: { 360 | Link: "www.examplelink.com", 361 | Name: "Xu table tamp", 362 | Settings: ["Office"], 363 | Vendor: ["reczC9ifQTdJpMZcx"], 364 | Color: ["Red", "White", "Shiny black", "Framboise"], 365 | Designer: ["rech4R2WIhpbaETDn"], 366 | "In stock": true, 367 | Type: "Lighting", 368 | Images: [ 369 | { 370 | id: "attp8Ld4TSuNkW4fK", 371 | width: 1000, 372 | height: 1333, 373 | url: "https://dl.airtable.com/.attachments/e39c0d197d24c696ffb8482bba38d939/559c4433/photo-1542728928-1413d1894ed1ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 374 | filename: 375 | "photo-1542728928-1413d1894ed1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 376 | size: 66269, 377 | type: "image/jpeg", 378 | thumbnails: { 379 | small: { 380 | url: "https://dl.airtable.com/.attachmentThumbnails/25d0921c3675e8d6f2e1d95f697a94fb/91731656", 381 | width: 27, 382 | height: 36, 383 | }, 384 | large: { 385 | url: "https://dl.airtable.com/.attachmentThumbnails/fc16fb8b6525bcc7b5eec00dcaeea4d1/66065a34", 386 | width: 512, 387 | height: 682, 388 | }, 389 | full: { 390 | url: "https://dl.airtable.com/.attachmentThumbnails/1757c7b5aa04c44145a0a62f1f1c68ef/406bfddc", 391 | width: 3000, 392 | height: 3000, 393 | }, 394 | }, 395 | }, 396 | ], 397 | Description: 398 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 399 | Materials: ["Brass"], 400 | "Size (WxLxH)": "16.75x8", 401 | "Unit cost": 864, 402 | "Total units sold": 0, 403 | "Gross sales": 0, 404 | }, 405 | createdTime: "2015-01-27T19:12:50.000Z", 406 | }, 407 | { 408 | id: "recZnPBw7PW80dmMM", 409 | fields: { 410 | Link: "www.examplelink.com", 411 | Name: "Strul rug", 412 | Settings: ["Living room", "Bedroom", "Office"], 413 | Vendor: ["reczC9ifQTdJpMZcx"], 414 | Color: ["Cream"], 415 | Designer: ["rec2jCgHzKymGnXQd"], 416 | "In stock": true, 417 | Type: "Rugs", 418 | Images: [ 419 | { 420 | id: "attQ4U3AUk9l8BCuY", 421 | width: 1132, 422 | height: 750, 423 | url: "https://dl.airtable.com/.attachments/de02834100d5cc4bd640669317512bbf/00e9bfa0/pexels-photo-317333.jpegautocompresscstinysrgbh750w1260", 424 | filename: "pexels-photo-317333.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260", 425 | size: 108134, 426 | type: "image/jpeg", 427 | thumbnails: { 428 | small: { 429 | url: "https://dl.airtable.com/.attachmentThumbnails/c13474dc83287a8b23c98a7ba5d101fe/5d46045d", 430 | width: 54, 431 | height: 36, 432 | }, 433 | large: { 434 | url: "https://dl.airtable.com/.attachmentThumbnails/06e19742f3183aead028c8affec72f99/4c54fd1a", 435 | width: 773, 436 | height: 512, 437 | }, 438 | full: { 439 | url: "https://dl.airtable.com/.attachmentThumbnails/166d0ab1064ff099c73f77ac65261f21/fcdc0a5c", 440 | width: 3000, 441 | height: 3000, 442 | }, 443 | }, 444 | }, 445 | ], 446 | Description: 447 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 448 | Materials: ["Indian wool"], 449 | "Size (WxLxH)": "10 x 8", 450 | "Unit cost": 3304.8, 451 | "Total units sold": 0, 452 | "Gross sales": 0, 453 | }, 454 | createdTime: "2015-01-27T19:36:06.000Z", 455 | }, 456 | { 457 | id: "recagDvwOLJFQghUN", 458 | fields: { 459 | Link: "www.examplelink.com", 460 | Name: "Pixellated rug", 461 | Settings: ["Living room"], 462 | Vendor: ["reczC9ifQTdJpMZcx"], 463 | Color: ["Cream", "Black", "Red"], 464 | Type: "Rugs", 465 | Orders: ["reccqEBrLMWz1ib2X"], 466 | Images: [ 467 | { 468 | id: "attLIFOa7ce7a5nDd", 469 | width: 1125, 470 | height: 750, 471 | url: "https://dl.airtable.com/.attachments/58b0e273798c5feafccbb8fb4255640b/89289dc9/pexels-photo-1482177.jpegautocompresscstinysrgbh750w1260", 472 | filename: "pexels-photo-1482177.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260", 473 | size: 133904, 474 | type: "image/jpeg", 475 | thumbnails: { 476 | small: { 477 | url: "https://dl.airtable.com/.attachmentThumbnails/0ba817848422a4ec5222e4a2778ab36c/f30fee95", 478 | width: 54, 479 | height: 36, 480 | }, 481 | large: { 482 | url: "https://dl.airtable.com/.attachmentThumbnails/c1a730dc6083043b3a4fc95baca50468/e7c729b6", 483 | width: 768, 484 | height: 512, 485 | }, 486 | full: { 487 | url: "https://dl.airtable.com/.attachmentThumbnails/a86dd6eb4340c2f9416570381c6f5f6c/cd3b8428", 488 | width: 3000, 489 | height: 3000, 490 | }, 491 | }, 492 | }, 493 | ], 494 | Description: 495 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 496 | Materials: ["Viscose", "Wool"], 497 | "Size (WxLxH)": "10x8x.25", 498 | "Unit cost": 2337.5, 499 | "Total units sold": 4, 500 | "Gross sales": 9350, 501 | }, 502 | createdTime: "2014-09-24T05:48:20.000Z", 503 | }, 504 | { 505 | id: "recbiEDrJOXHWfn4W", 506 | fields: { 507 | Notes: "Back in stock!", 508 | Link: "www.examplelink.com", 509 | Name: "Samari bookshelf", 510 | Settings: ["Living room"], 511 | Vendor: ["rec2nan8zh3q75IlN"], 512 | Color: ["Brown"], 513 | Designer: ["recubaiUEWwy7qlNj"], 514 | "In stock": true, 515 | Type: "Bookshelves", 516 | Orders: ["rec9nEwvUPVLRbgQL"], 517 | Images: [ 518 | { 519 | id: "attzPYQlRQIaZC2gi", 520 | width: 1000, 521 | height: 664, 522 | url: "https://dl.airtable.com/.attachments/1d5da6ab7b5aaa85422470df41f4028f/41f6cfaf/photo-1543248939-4296e1fea89bixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9w1000q80", 523 | filename: 524 | "photo-1543248939-4296e1fea89b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&w=1000&q=80", 525 | size: 118682, 526 | type: "image/jpeg", 527 | thumbnails: { 528 | small: { 529 | url: "https://dl.airtable.com/.attachmentThumbnails/0bfc00bcc317b91ede93541ac9992c6e/e2d95a38", 530 | width: 54, 531 | height: 36, 532 | }, 533 | large: { 534 | url: "https://dl.airtable.com/.attachmentThumbnails/7a3b89cd44914b9db86eb00357471871/e66c4135", 535 | width: 771, 536 | height: 512, 537 | }, 538 | full: { 539 | url: "https://dl.airtable.com/.attachmentThumbnails/5bea028b2b1ffaa3f810d733ce86eea8/07c4361c", 540 | width: 3000, 541 | height: 3000, 542 | }, 543 | }, 544 | }, 545 | ], 546 | Description: 547 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 548 | Materials: ["Light wood"], 549 | "Size (WxLxH)": "12x7x8", 550 | "Unit cost": 3585, 551 | "Total units sold": 5, 552 | "Gross sales": 17925, 553 | }, 554 | createdTime: "2014-09-26T00:32:12.000Z", 555 | }, 556 | { 557 | id: "recdjJvoUWPJ04jZS", 558 | fields: { 559 | Link: "www.examplelink.com", 560 | Name: "Kelly Sall light", 561 | Settings: ["Dining"], 562 | Vendor: ["rec2nan8zh3q75IlN"], 563 | Color: ["Gold", "Black"], 564 | Designer: ["receHjd3RuI4pwWPv"], 565 | Type: "Lighting", 566 | Orders: ["rec3eJzCPHKDUfmYU", "rec1kMDDULQEV4mUJ"], 567 | Images: [ 568 | { 569 | id: "att6wJT4f86PPXO6E", 570 | width: 1000, 571 | height: 1500, 572 | url: "https://dl.airtable.com/.attachments/4ef0810bf457ccd389d36d4a4a25eddc/d3d18b97/photo-1540932239986-30128078f3c5ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 573 | filename: 574 | "photo-1540932239986-30128078f3c5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 575 | size: 96926, 576 | type: "image/jpeg", 577 | thumbnails: { 578 | small: { 579 | url: "https://dl.airtable.com/.attachmentThumbnails/3995075be79779ea6f3a4631dfa190ca/afe905b5", 580 | width: 24, 581 | height: 36, 582 | }, 583 | large: { 584 | url: "https://dl.airtable.com/.attachmentThumbnails/17c8a69cac3a5cb225a63d20035083dd/473a3e62", 585 | width: 512, 586 | height: 768, 587 | }, 588 | full: { 589 | url: "https://dl.airtable.com/.attachmentThumbnails/79c7e3a08f88fbc5ac1f39a3796544f8/4d6314d5", 590 | width: 3000, 591 | height: 3000, 592 | }, 593 | }, 594 | }, 595 | ], 596 | Description: 597 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 598 | Materials: ["Brass", "Iron"], 599 | "Size (WxLxH)": "3x1x5", 600 | "Unit cost": 1950, 601 | "Total units sold": 10, 602 | "Gross sales": 19500, 603 | }, 604 | createdTime: "2014-09-26T01:46:24.000Z", 605 | }, 606 | { 607 | id: "recesbIYWDz7gzmUa", 608 | fields: { 609 | Link: "www.examplelink.com", 610 | Name: "Traverse coffee table", 611 | Settings: ["Living room"], 612 | Vendor: ["rec2nan8zh3q75IlN"], 613 | Color: ["Brown"], 614 | Designer: ["recCOx3aLV4dtIwj0"], 615 | "In stock": true, 616 | Type: "Tables", 617 | Images: [ 618 | { 619 | id: "attvvjid4qZ61YIXs", 620 | width: 1000, 621 | height: 714, 622 | url: "https://dl.airtable.com/.attachments/cf130facf9882661f456094d55c719a2/5f8c90cd/photo-1461418126083-a84e9ca935deixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 623 | filename: 624 | "photo-1461418126083-a84e9ca935de?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 625 | size: 73526, 626 | type: "image/jpeg", 627 | thumbnails: { 628 | small: { 629 | url: "https://dl.airtable.com/.attachmentThumbnails/a5fa7521c04a36874d88e88422201a84/fd3b2287", 630 | width: 50, 631 | height: 36, 632 | }, 633 | large: { 634 | url: "https://dl.airtable.com/.attachmentThumbnails/54cce4443242a9ae655f66efd769c005/ae0835e2", 635 | width: 717, 636 | height: 512, 637 | }, 638 | full: { 639 | url: "https://dl.airtable.com/.attachmentThumbnails/3d54e0b5ce4f23ca9103f2536969365e/b9fe715f", 640 | width: 3000, 641 | height: 3000, 642 | }, 643 | }, 644 | }, 645 | ], 646 | Description: 647 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 648 | Materials: ["Reclaimed wood"], 649 | "Size (WxLxH)": "31.5x31.5x13.75", 650 | "Unit cost": 756.5, 651 | "Total units sold": 0, 652 | "Gross sales": 0, 653 | }, 654 | createdTime: "2015-01-27T20:59:52.000Z", 655 | }, 656 | { 657 | id: "rech1fci5RLv5Wuya", 658 | fields: { 659 | Link: "www.examplelink.com", 660 | Name: "Zig-zag rug", 661 | Settings: ["Bedroom"], 662 | Vendor: ["reczC9ifQTdJpMZcx"], 663 | Color: ["Black", "White"], 664 | "In stock": true, 665 | Type: "Rugs", 666 | Images: [ 667 | { 668 | id: "attKBGaNSHgD2Dsj1", 669 | width: 1000, 670 | height: 1500, 671 | url: "https://dl.airtable.com/.attachments/f026e3efd17cba2d1b9edfb73cd1d955/deb749bf/photo-1557374669-d1f478dc92c4ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 672 | filename: 673 | "photo-1557374669-d1f478dc92c4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 674 | size: 292705, 675 | type: "image/jpeg", 676 | thumbnails: { 677 | small: { 678 | url: "https://dl.airtable.com/.attachmentThumbnails/b5b74dc7f3b9151307872724ebe2fcff/e0aacacb", 679 | width: 24, 680 | height: 36, 681 | }, 682 | large: { 683 | url: "https://dl.airtable.com/.attachmentThumbnails/8e5d586083fce3b396b7e5e3b01dc2b9/e85ac9cd", 684 | width: 512, 685 | height: 768, 686 | }, 687 | full: { 688 | url: "https://dl.airtable.com/.attachmentThumbnails/5586d8013a092653199b89ec519d1cf1/9da8ed3b", 689 | width: 3000, 690 | height: 3000, 691 | }, 692 | }, 693 | }, 694 | ], 695 | Description: 696 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 697 | Materials: ["Wool", "Viscose"], 698 | "Size (WxLxH)": "8x10 and 10x12", 699 | "Unit cost": 2337.5, 700 | "Total units sold": 0, 701 | "Gross sales": 0, 702 | }, 703 | createdTime: "2015-01-27T19:55:16.000Z", 704 | }, 705 | { 706 | id: "recilWUjuW32MFFQJ", 707 | fields: { 708 | Link: "www.examplelink.com", 709 | Name: "Phona Shanta rug", 710 | Settings: ["Bedroom"], 711 | Vendor: ["rec2nan8zh3q75IlN"], 712 | Color: ["Red", "Blue", "Cream", "Cherry"], 713 | "In stock": true, 714 | Type: "Rugs", 715 | Images: [ 716 | { 717 | id: "attwZEARu38xurYIs", 718 | width: 1000, 719 | height: 1250, 720 | url: "https://dl.airtable.com/.attachments/17caf0be597b8296abe24165e6681b29/6309048f/photo-1534889156217-d643df14f14aixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 721 | filename: 722 | "photo-1534889156217-d643df14f14a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 723 | size: 257324, 724 | type: "image/jpeg", 725 | thumbnails: { 726 | small: { 727 | url: "https://dl.airtable.com/.attachmentThumbnails/4bbd5a66971e35be391f56051ba69fcb/3bbd612d", 728 | width: 29, 729 | height: 36, 730 | }, 731 | large: { 732 | url: "https://dl.airtable.com/.attachmentThumbnails/ab1950ddc22f4c330401dcc96aead0a0/66cc844e", 733 | width: 512, 734 | height: 640, 735 | }, 736 | full: { 737 | url: "https://dl.airtable.com/.attachmentThumbnails/297cc3d71aaa87689824b8d40c6775e1/4e91d8cf", 738 | width: 3000, 739 | height: 3000, 740 | }, 741 | }, 742 | }, 743 | ], 744 | Description: 745 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 746 | Materials: ["Wool", "Cotton"], 747 | "Size (WxLxH)": "6'x9' and 9'x12'", 748 | "Unit cost": 680, 749 | "Total units sold": 0, 750 | "Gross sales": 0, 751 | }, 752 | createdTime: "2015-01-27T21:21:16.000Z", 753 | }, 754 | { 755 | id: "recmXaFhfBq0UW8JG", 756 | fields: { 757 | Link: "www.examplelink.com", 758 | Name: "Dual extension table", 759 | Settings: ["Living room", "Office"], 760 | Vendor: ["reczC9ifQTdJpMZcx"], 761 | Color: ["Beige", "Brown", "White"], 762 | Designer: ["recwetAjjaWP4PP4p"], 763 | Type: "Tables", 764 | Images: [ 765 | { 766 | id: "attPLeBxjQ5P7S6U5", 767 | width: 1000, 768 | height: 667, 769 | url: "https://dl.airtable.com/.attachments/8a16fbbdf733eba3729525ff3ce086a6/c4e24bed/photo-1530018607912-eff2daa1bac4ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 770 | filename: 771 | "photo-1530018607912-eff2daa1bac4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 772 | size: 56969, 773 | type: "image/jpeg", 774 | thumbnails: { 775 | small: { 776 | url: "https://dl.airtable.com/.attachmentThumbnails/6091124e504b31a786d6fb0ef7f5086f/a6f865a9", 777 | width: 54, 778 | height: 36, 779 | }, 780 | large: { 781 | url: "https://dl.airtable.com/.attachmentThumbnails/7cc91cfd43bdcefabfaef6dfb2dc29ae/2b4205f3", 782 | width: 768, 783 | height: 512, 784 | }, 785 | full: { 786 | url: "https://dl.airtable.com/.attachmentThumbnails/6ca4493666279439287f1475e9730e46/3efda09c", 787 | width: 3000, 788 | height: 3000, 789 | }, 790 | }, 791 | }, 792 | ], 793 | Description: 794 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 795 | Materials: ["Dark wood", "Light wood"], 796 | "Size (WxLxH)": "35.5x29.5", 797 | "Unit cost": 4350, 798 | "Total units sold": 0, 799 | "Gross sales": 0, 800 | }, 801 | createdTime: "2015-01-27T19:42:25.000Z", 802 | }, 803 | { 804 | id: "recqatIR3nDJE8SDV", 805 | fields: { 806 | Link: "www.examplelink.com", 807 | Name: "Rectangular sofa in Blue moon", 808 | Settings: ["Living room", "Office"], 809 | Vendor: ["reczC9ifQTdJpMZcx"], 810 | Color: ["Blue"], 811 | Designer: ["recmAQKRCCi4Dz6RR"], 812 | "In stock": true, 813 | Type: "Sofas", 814 | Orders: ["recbOpgZRIhi86RnT"], 815 | Images: [ 816 | { 817 | id: "att6mjGjkzKCvrTzj", 818 | width: 1090, 819 | height: 750, 820 | url: "https://dl.airtable.com/.attachments/7de357d61da52a8356c51336f991ab99/99188c97/pexels-photo-1282315.jpegautocompresscstinysrgbh750w1260", 821 | filename: "pexels-photo-1282315.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260", 822 | size: 51195, 823 | type: "image/jpeg", 824 | thumbnails: { 825 | small: { 826 | url: "https://dl.airtable.com/.attachmentThumbnails/d0bb425a528119db9ab133547b4e15d2/58c5973e", 827 | width: 52, 828 | height: 36, 829 | }, 830 | large: { 831 | url: "https://dl.airtable.com/.attachmentThumbnails/1c251b6fd8143b92167b9d66b353039c/07650c41", 832 | width: 744, 833 | height: 512, 834 | }, 835 | full: { 836 | url: "https://dl.airtable.com/.attachmentThumbnails/38c75f6bfacb1e30d1775ac3722e39c5/2c3f66ca", 837 | width: 3000, 838 | height: 3000, 839 | }, 840 | }, 841 | }, 842 | ], 843 | Description: 844 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 845 | Materials: ["Suede"], 846 | "Size (WxLxH)": "80x25.5", 847 | "Unit cost": 3782.5, 848 | "Total units sold": 1, 849 | "Gross sales": 3782.5, 850 | }, 851 | createdTime: "2015-01-27T20:56:53.000Z", 852 | }, 853 | { 854 | id: "recqkdaSiie4njMmh", 855 | fields: { 856 | Link: "www.examplelink.com", 857 | Name: "Gedo bench", 858 | Settings: ["Office"], 859 | Vendor: ["rec2nan8zh3q75IlN"], 860 | Color: ["Brown"], 861 | Designer: ["recBcfJfq81I96sb9"], 862 | "In stock": true, 863 | Type: "Chairs", 864 | Images: [ 865 | { 866 | id: "attm4rKaRc3O4JGr0", 867 | width: 1000, 868 | height: 669, 869 | url: "https://dl.airtable.com/.attachments/263a1a01bdf60843283fac826f816bef/6dd81037/photo-1499364781141-6f7bf8bbc8dbixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 870 | filename: 871 | "photo-1499364781141-6f7bf8bbc8db?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 872 | size: 149965, 873 | type: "image/jpeg", 874 | thumbnails: { 875 | small: { 876 | url: "https://dl.airtable.com/.attachmentThumbnails/9b91221f7eefea7581b03aef1966c1c5/7da10685", 877 | width: 54, 878 | height: 36, 879 | }, 880 | large: { 881 | url: "https://dl.airtable.com/.attachmentThumbnails/17a9bd2c19aefc0f5ce12d98d17a7828/88d56a53", 882 | width: 765, 883 | height: 512, 884 | }, 885 | full: { 886 | url: "https://dl.airtable.com/.attachmentThumbnails/69e726d86a4d3ef69ee08704b00b1397/9fc65774", 887 | width: 3000, 888 | height: 3000, 889 | }, 890 | }, 891 | }, 892 | ], 893 | Description: 894 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 895 | Materials: ["Reclaimed wood"], 896 | "Size (WxLxH)": "16.5x60x18", 897 | "Unit cost": 1575, 898 | "Total units sold": 0, 899 | "Gross sales": 0, 900 | }, 901 | createdTime: "2015-01-27T20:15:28.000Z", 902 | }, 903 | { 904 | id: "rectIFJB1ao5OHLf0", 905 | fields: { 906 | Link: "www.examplelink.com", 907 | Name: "Dita side table", 908 | Settings: ["Office", "Bedroom"], 909 | Vendor: ["rec2nan8zh3q75IlN"], 910 | Color: ["White", "Brown"], 911 | "In stock": true, 912 | Type: "Tables", 913 | Images: [ 914 | { 915 | id: "attw1WlLKTAd2HZLo", 916 | width: 1000, 917 | height: 1333, 918 | url: "https://dl.airtable.com/.attachments/a7940e6d1f94256db11b1e17b513390a/c8b51347/photo-1499933374294-4584851497ccixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 919 | filename: 920 | "photo-1499933374294-4584851497cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 921 | size: 196649, 922 | type: "image/jpeg", 923 | thumbnails: { 924 | small: { 925 | url: "https://dl.airtable.com/.attachmentThumbnails/6fb995acfec3a8dd6785346e62218edb/11181100", 926 | width: 27, 927 | height: 36, 928 | }, 929 | large: { 930 | url: "https://dl.airtable.com/.attachmentThumbnails/d3e8499b961622820704d1dcb8e3ab6a/35246ba7", 931 | width: 512, 932 | height: 682, 933 | }, 934 | full: { 935 | url: "https://dl.airtable.com/.attachmentThumbnails/98fc6bf2ae5c6c27e670d16676c98445/122852cc", 936 | width: 3000, 937 | height: 3000, 938 | }, 939 | }, 940 | }, 941 | ], 942 | Description: 943 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 944 | Materials: ["Solid teak", "Light wood"], 945 | "Size (WxLxH)": '16.75"Wx16.5"Dx22.75"H', 946 | "Unit cost": 349, 947 | "Total units sold": 0, 948 | "Gross sales": 0, 949 | }, 950 | createdTime: "2015-01-27T21:26:38.000Z", 951 | }, 952 | { 953 | id: "recx5eatp5eIb9wb1", 954 | fields: { 955 | Link: "www.examplelink.com", 956 | Name: "Cow rug", 957 | Settings: ["Bedroom", "Office", "Living room"], 958 | Vendor: ["rec2nan8zh3q75IlN"], 959 | Color: ["Black", "White", "Brown"], 960 | "In stock": true, 961 | Type: "Rugs", 962 | Images: [ 963 | { 964 | id: "attrXc2yciDEY8ek3", 965 | width: 1000, 966 | height: 667, 967 | url: "https://dl.airtable.com/.attachments/652bd4d06d588d20203aa83641a10c8e/7f3d1b12/photo-1499955085172-a104c9463eceixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9w1000q80", 968 | filename: 969 | "photo-1499955085172-a104c9463ece?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&w=1000&q=80", 970 | size: 105083, 971 | type: "image/jpeg", 972 | thumbnails: { 973 | small: { 974 | url: "https://dl.airtable.com/.attachmentThumbnails/7bc190fcd4e767bd00ade81e04d9827b/8472d701", 975 | width: 54, 976 | height: 36, 977 | }, 978 | large: { 979 | url: "https://dl.airtable.com/.attachmentThumbnails/20b8118b8c14da02364082ff97bbc1af/d948c8e3", 980 | width: 768, 981 | height: 512, 982 | }, 983 | full: { 984 | url: "https://dl.airtable.com/.attachmentThumbnails/c4114e48121f78623e5f98b5bd305d89/973b9fec", 985 | width: 3000, 986 | height: 3000, 987 | }, 988 | }, 989 | }, 990 | ], 991 | Description: 992 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 993 | Materials: ["Leather cowhide"], 994 | "Size (WxLxH)": "4'9\"x5'10\"", 995 | "Unit cost": 2008.55, 996 | "Total units sold": 0, 997 | "Gross sales": 0, 998 | }, 999 | createdTime: "2015-01-27T21:21:15.000Z", 1000 | }, 1001 | ], 1002 | }; 1003 | 1004 | const searcher = new JSONHeroSearch(json, { cacheSettings: { enabled: false } }); 1005 | searcher.prepareIndex(); 1006 | 1007 | for (let i = 0; i < 10; i++) { 1008 | searcher.search("url"); 1009 | } 1010 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from "@rollup/plugin-node-resolve"; 2 | import typescript from "rollup-plugin-typescript2"; 3 | import pkg from "./package.json"; 4 | 5 | export default [ 6 | // CommonJS 7 | { 8 | input: "src/index.ts", 9 | external: [...Object.keys(pkg.dependencies || {})], 10 | plugins: [ 11 | nodeResolve({ 12 | extensions: [".ts"], 13 | }), 14 | typescript(), 15 | ], 16 | output: [{ file: pkg.main, format: "cjs" }], 17 | }, 18 | // ES 19 | { 20 | input: "src/index.ts", 21 | external: [...Object.keys(pkg.dependencies || {})], 22 | plugins: [ 23 | nodeResolve({ 24 | extensions: [".ts"], 25 | }), 26 | typescript(), 27 | ], 28 | output: [{ file: pkg.module, format: "es" }], 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /src/JSONHeroSearch.ts: -------------------------------------------------------------------------------- 1 | import LRUCache from "lru-cache"; 2 | import { IItemAccessor, ItemScore, prepareQuery } from "./fuzzyScoring"; 3 | import { search, SearchResult } from "./search"; 4 | 5 | export type JSONHeroSearchFormatter = (value: unknown) => string | undefined; 6 | 7 | export type JSONHeroSearchOptions = { 8 | cacheSettings?: { 9 | enabled?: boolean; 10 | max?: number; 11 | }; 12 | accessor?: IItemAccessor; 13 | formatter?: JSONHeroSearchFormatter; 14 | }; 15 | 16 | export class JSONHeroSearch { 17 | json: unknown; 18 | items: string[]; 19 | scoreCache: Map = new Map(); 20 | searchCache: LRUCache>>; 21 | options: Required>; 22 | 23 | constructor(json: unknown, options?: JSONHeroSearchOptions) { 24 | this.json = json; 25 | this.items = []; 26 | this.options = { 27 | cacheSettings: { 28 | enabled: true, 29 | max: 100, 30 | ...options?.cacheSettings, 31 | }, 32 | accessor: new JSONHeroSearchAccessor(this.json, options?.formatter ?? defaultFormatter), 33 | ...options, 34 | }; 35 | 36 | this.searchCache = new LRUCache>>({ 37 | max: options?.cacheSettings?.max ?? 100, 38 | }); 39 | } 40 | 41 | prepareIndex() { 42 | if (this.items.length > 0) { 43 | return; 44 | } 45 | 46 | this.items = getAllPaths(this.json); 47 | } 48 | 49 | search(query: string): Array> { 50 | if (this.options.cacheSettings.enabled && this.searchCache.has(query)) { 51 | return this.searchCache.get(query) ?? []; 52 | } 53 | 54 | this.prepareIndex(); 55 | 56 | const preparedQuery = prepareQuery(query); 57 | 58 | const results = search(this.items, preparedQuery, true, this.options.accessor, this.scoreCache); 59 | 60 | if (this.options.cacheSettings.enabled) this.searchCache.set(query, results); 61 | 62 | return results; 63 | } 64 | } 65 | 66 | function lastComponent(path: string): string { 67 | const components = path.split("."); 68 | 69 | return components[components.length - 1]; 70 | } 71 | 72 | function isArray(path: string): boolean { 73 | const last = lastComponent(path); 74 | 75 | return last.match(/^\d+$/) !== null; 76 | } 77 | 78 | export class JSONHeroSearchAccessor implements IItemAccessor { 79 | json: unknown; 80 | formatter: JSONHeroSearchFormatter; 81 | valueCache: Map = new Map(); 82 | 83 | constructor(json: unknown, formatter: JSONHeroSearchFormatter) { 84 | this.json = json; 85 | this.formatter = formatter; 86 | } 87 | 88 | getIsArrayItem(path: string): boolean { 89 | return isArray(path); 90 | } 91 | 92 | getItemLabel(path: string): string { 93 | return lastComponent(path); 94 | } 95 | 96 | getItemDescription(path: string): string { 97 | // Get all but the first and last component 98 | const components = path.split(".").slice(1, -1); 99 | 100 | return components.join("."); 101 | } 102 | 103 | getItemPath(path: string): string { 104 | // Get all but the first component 105 | const components = path.split(".").slice(1); 106 | 107 | return components.join("."); 108 | } 109 | 110 | getRawValue(path: string): string | undefined { 111 | const cacheKey = `${path}_raw`; 112 | 113 | if (this.valueCache.has(cacheKey)) { 114 | return this.valueCache.get(cacheKey); 115 | } 116 | 117 | const rawValue = doGetRawValue(this.json); 118 | 119 | if (rawValue) { 120 | this.valueCache.set(cacheKey, rawValue); 121 | } 122 | 123 | return rawValue; 124 | 125 | function doGetRawValue(json: unknown) { 126 | const result = getFirstAtPath(json, path); 127 | 128 | if (typeof result === "string") { 129 | return result; 130 | } 131 | 132 | if (typeof result === "boolean") { 133 | return result ? "true" : "false"; 134 | } 135 | 136 | if (result === "null") { 137 | return "null"; 138 | } 139 | 140 | if (typeof result === "number") { 141 | return result.toString(); 142 | } 143 | } 144 | } 145 | 146 | getFormattedValue(path: string): string | undefined { 147 | const cacheKey = `${path}_formatted`; 148 | 149 | if (this.valueCache.has(cacheKey)) { 150 | return this.valueCache.get(cacheKey); 151 | } 152 | 153 | const formattedValue = doGetFormattedValue(this.json, this.formatter); 154 | 155 | if (formattedValue) { 156 | this.valueCache.set(cacheKey, formattedValue); 157 | } 158 | 159 | return formattedValue; 160 | 161 | function doGetFormattedValue(json: unknown, formatter: JSONHeroSearchFormatter) { 162 | const result = getFirstAtPath(json, path); 163 | 164 | return formatter(result); 165 | } 166 | } 167 | } 168 | 169 | function getAllPaths(json: unknown): Array { 170 | const paths: Array = []; 171 | 172 | function walk(json: unknown, path: string) { 173 | paths.push(path); 174 | 175 | if (Array.isArray(json)) { 176 | for (let i = 0; i < json.length; i++) { 177 | walk(json[i], `${path}.${i}`); 178 | } 179 | } else if (typeof json === "object" && json !== null) { 180 | for (const key of Object.keys(json)) { 181 | walk(json[key as keyof typeof json], `${path}.${key}`); 182 | } 183 | } 184 | } 185 | 186 | walk(json, "$"); 187 | 188 | return paths; 189 | } 190 | 191 | function getFirstAtPath(json: unknown, path: string): unknown { 192 | let result = json; 193 | 194 | const components = path.split("."); 195 | 196 | for (const component of components) { 197 | if (component === "$") { 198 | continue; 199 | } 200 | 201 | if (result === undefined) { 202 | return; 203 | } 204 | 205 | if (Array.isArray(result) && component.match(/^\d+$/)) { 206 | result = result[Number(component)]; 207 | } else { 208 | if (typeof result === "object" && result !== null) { 209 | result = result[component as keyof typeof result]; 210 | } else { 211 | return result; 212 | } 213 | } 214 | } 215 | 216 | return result; 217 | } 218 | 219 | function defaultFormatter(value: unknown): string | undefined { 220 | if (typeof value === "string") { 221 | return value; 222 | } 223 | 224 | if (typeof value === "boolean") { 225 | return value ? "true" : "false"; 226 | } 227 | 228 | if (value === null) { 229 | return "null"; 230 | } 231 | 232 | if (typeof value === "number") { 233 | return value.toString(); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/fuzzyScoring.ts: -------------------------------------------------------------------------------- 1 | import { hash } from "./hash"; 2 | import { IItemAccessor, scoreFuzzy } from "./scoring"; 3 | import * as strings from "./strings"; 4 | 5 | export type Match = { 6 | start: number; 7 | end: number; 8 | }; 9 | 10 | export type ItemScore = { 11 | /** 12 | * Overall score. 13 | */ 14 | score: number; 15 | 16 | label?: string; 17 | /** 18 | * Matches within the label. 19 | */ 20 | labelMatch?: Match[]; 21 | 22 | description?: string; 23 | descriptionMatch?: Match[]; 24 | 25 | rawValue?: string; 26 | /** 27 | * Matches within the rawValue. 28 | * Only available if the item has a rawValue. 29 | * 30 | * @see JsonItem.rawValue 31 | */ 32 | rawValueMatch?: Match[]; 33 | 34 | formattedValue?: string; 35 | /** 36 | * Matches within the formattedValue. 37 | * Only available if the item has a formattedValue. 38 | * 39 | * @see JsonItem.formattedValue 40 | */ 41 | formattedValueMatch?: Match[]; 42 | }; 43 | 44 | export type PreparedQueryPiece = { 45 | /** 46 | * The original query as provided as input. 47 | */ 48 | original: string; 49 | originalLowercase: string; 50 | 51 | /** 52 | * Normalizes paths and removes wildcards 53 | * from the query. 54 | * 55 | * So if the query is `"foo/bar"`, the normalized 56 | * query is `foo.bar`. 57 | */ 58 | pathNormalized: string; 59 | 60 | /** 61 | * In addition to the normalized path, will have 62 | * whitespace and wildcards removed. 63 | */ 64 | normalized: string; 65 | normalizedLowercase: string; 66 | 67 | /** 68 | * The query is wrapped in quotes which means 69 | * this query must be a substring of the input. 70 | * In other words, no fuzzy matching is used. 71 | */ 72 | expectContiguousMatch: boolean; 73 | }; 74 | 75 | export type PreparedQuery = PreparedQueryPiece & { 76 | /** 77 | * Query split by spaces into pieces. 78 | */ 79 | values: PreparedQueryPiece[] | undefined; 80 | 81 | /** 82 | * Whether the query contains path separator(s) or not. 83 | */ 84 | containsPathSeparator: boolean; 85 | }; 86 | 87 | export { FuzzyScore, IItemAccessor, scoreFuzzy } from "./scoring"; 88 | 89 | const MULTIPLE_QUERY_VALUES_SEPARATOR = " "; 90 | export function prepareQuery(original: string): PreparedQuery { 91 | if (typeof original !== "string") { 92 | original = ""; 93 | } 94 | 95 | const originalLowercase = original.toLowerCase(); 96 | const { pathNormalized, normalized, normalizedLowercase } = normalizeQuery(original); 97 | const containsPathSeparator = pathNormalized.indexOf(".") >= 0; 98 | const expectExactMatch = queryExpectsExactMatch(original); 99 | 100 | let values: PreparedQueryPiece[] | undefined = undefined; 101 | 102 | const originalSplit = original.split(MULTIPLE_QUERY_VALUES_SEPARATOR); 103 | if (originalSplit.length > 1) { 104 | for (const originalPiece of originalSplit) { 105 | const expectExactMatchPiece = queryExpectsExactMatch(originalPiece); 106 | const { 107 | pathNormalized: pathNormalizedPiece, 108 | normalized: normalizedPiece, 109 | normalizedLowercase: normalizedLowercasePiece, 110 | } = normalizeQuery(originalPiece); 111 | 112 | if (normalizedPiece) { 113 | if (!values) { 114 | values = []; 115 | } 116 | 117 | values.push({ 118 | original: originalPiece, 119 | originalLowercase: originalPiece.toLowerCase(), 120 | pathNormalized: pathNormalizedPiece, 121 | normalized: normalizedPiece, 122 | normalizedLowercase: normalizedLowercasePiece, 123 | expectContiguousMatch: expectExactMatchPiece, 124 | }); 125 | } 126 | } 127 | } 128 | 129 | return { 130 | original, 131 | originalLowercase, 132 | pathNormalized, 133 | normalized, 134 | normalizedLowercase, 135 | values, 136 | containsPathSeparator, 137 | expectContiguousMatch: expectExactMatch, 138 | }; 139 | } 140 | 141 | function normalizeQuery(original: string): { 142 | pathNormalized: string; 143 | normalized: string; 144 | normalizedLowercase: string; 145 | } { 146 | const pathNormalized = original.replace(/\//g, "."); // Help Windows users to search for paths when using slash 147 | 148 | // we remove quotes here because quotes are used for exact match search 149 | const normalized = stripWildcards(pathNormalized).replace(/\s|"/g, ""); 150 | 151 | return { 152 | pathNormalized, 153 | normalized, 154 | normalizedLowercase: normalized.toLowerCase(), 155 | }; 156 | } 157 | 158 | export function stripWildcards(pattern: string): string { 159 | return pattern.replace(/\*/g, ""); 160 | } 161 | 162 | function queryExpectsExactMatch(query: string) { 163 | return query.startsWith('"') && query.endsWith('"'); 164 | } 165 | 166 | const NO_ITEM_SCORE = Object.freeze({ score: 0 }); 167 | 168 | export function scoreItemFuzzy( 169 | item: T, 170 | query: PreparedQuery, 171 | allowNonContiguousMatches: boolean, 172 | accessor: IItemAccessor, 173 | cache = new Map(), 174 | ): ItemScore { 175 | if (!item || !query.normalized) { 176 | return NO_ITEM_SCORE; // we need an item and query to score on at least 177 | } 178 | 179 | const label = accessor.getItemLabel(item); 180 | if (!label) { 181 | return NO_ITEM_SCORE; // we need a label at least 182 | } 183 | 184 | const description = accessor.getItemDescription(item); 185 | 186 | const path = accessor.getItemPath(item); 187 | 188 | const rawValue = accessor.getRawValue(item); 189 | 190 | const formattedValue = accessor.getFormattedValue(item); 191 | 192 | const cacheHash = getCacheHash( 193 | label, 194 | description, 195 | path, 196 | rawValue, 197 | formattedValue, 198 | allowNonContiguousMatches, 199 | query, 200 | ); 201 | const cached = cache.get(cacheHash); 202 | if (cached) { 203 | return cached; 204 | } 205 | 206 | const itemScore = doScoreItemFuzzy( 207 | label, 208 | description, 209 | path, 210 | rawValue, 211 | formattedValue, 212 | query, 213 | allowNonContiguousMatches, 214 | ); 215 | 216 | cache.set(cacheHash, itemScore); 217 | 218 | return itemScore; 219 | } 220 | 221 | const PATH_IDENTITY_SCORE = 1 << 18; 222 | const LABEL_PREFIX_SCORE_THRESHOLD = 1 << 17; 223 | const LABEL_SCORE_THRESHOLD = 1 << 16; 224 | 225 | function doScoreItemFuzzy( 226 | label: string, 227 | description: string | undefined, 228 | path: string | undefined, 229 | rawValue: string | undefined, 230 | formattedValue: string | undefined, 231 | query: PreparedQuery, 232 | allowNonContiguousMatches: boolean, 233 | ): ItemScore { 234 | const preferLabelMatches = !path || !query.containsPathSeparator; 235 | 236 | // Treat identity matches on full path highest 237 | if (path && query.pathNormalized === path) { 238 | return { 239 | score: PATH_IDENTITY_SCORE, 240 | labelMatch: [{ start: 0, end: label.length }], 241 | descriptionMatch: description ? [{ start: 0, end: description.length }] : undefined, 242 | label, 243 | description, 244 | rawValue, 245 | formattedValue, 246 | }; 247 | } 248 | 249 | // Score: multiple inputs 250 | if (query.values && query.values.length > 1) { 251 | return doScoreItemFuzzyMultiple( 252 | label, 253 | description, 254 | path, 255 | rawValue, 256 | formattedValue, 257 | query.values, 258 | preferLabelMatches, 259 | allowNonContiguousMatches, 260 | ); 261 | } 262 | 263 | // Score: single input 264 | return doScoreItemFuzzySingle( 265 | label, 266 | description, 267 | path, 268 | rawValue, 269 | formattedValue, 270 | query, 271 | preferLabelMatches, 272 | allowNonContiguousMatches, 273 | ); 274 | } 275 | 276 | function doScoreItemFuzzyMultiple( 277 | label: string, 278 | description: string | undefined, 279 | path: string | undefined, 280 | rawValue: string | undefined, 281 | formattedValue: string | undefined, 282 | query: PreparedQueryPiece[], 283 | preferLabelMatches: boolean, 284 | allowNonContiguousMatches: boolean, 285 | ): ItemScore { 286 | let totalScore = 0; 287 | const totalLabelMatches: Match[] = []; 288 | const totalDescriptionMatches: Match[] = []; 289 | const totalRawValueMatches: Match[] = []; 290 | const totalFormattedValueMatches: Match[] = []; 291 | 292 | for (const queryPiece of query) { 293 | const { score, labelMatch, descriptionMatch, rawValueMatch, formattedValueMatch } = 294 | doScoreItemFuzzySingle( 295 | label, 296 | description, 297 | path, 298 | rawValue, 299 | formattedValue, 300 | queryPiece, 301 | preferLabelMatches, 302 | allowNonContiguousMatches, 303 | ); 304 | if (score === 0) { 305 | // if a single query value does not match, return with 306 | // no score entirely, we require all queries to match 307 | return NO_ITEM_SCORE; 308 | } 309 | 310 | totalScore += score; 311 | if (labelMatch) { 312 | totalLabelMatches.push(...labelMatch); 313 | } 314 | 315 | if (descriptionMatch) { 316 | totalDescriptionMatches.push(...descriptionMatch); 317 | } 318 | 319 | if (rawValueMatch) { 320 | totalRawValueMatches.push(...rawValueMatch); 321 | } 322 | 323 | if (formattedValueMatch) { 324 | totalFormattedValueMatches.push(...formattedValueMatch); 325 | } 326 | } 327 | 328 | // if we have a score, ensure that the positions are 329 | // sorted in ascending order and distinct 330 | return { 331 | score: totalScore, 332 | labelMatch: totalLabelMatches.length > 0 ? normalizeMatches(totalLabelMatches) : undefined, 333 | descriptionMatch: 334 | totalDescriptionMatches.length > 0 ? normalizeMatches(totalDescriptionMatches) : undefined, 335 | rawValueMatch: 336 | totalRawValueMatches.length > 0 ? normalizeMatches(totalRawValueMatches) : undefined, 337 | formattedValueMatch: 338 | totalFormattedValueMatches.length > 0 339 | ? normalizeMatches(totalFormattedValueMatches) 340 | : undefined, 341 | label, 342 | description, 343 | rawValue, 344 | formattedValue, 345 | }; 346 | } 347 | 348 | function doScoreItemFuzzySingle( 349 | label: string, 350 | description: string | undefined, 351 | path: string | undefined, 352 | rawValue: string | undefined, 353 | formattedValue: string | undefined, 354 | query: PreparedQueryPiece, 355 | preferLabelMatches: boolean, 356 | allowNonContiguousMatches: boolean, 357 | ): ItemScore { 358 | // Prefer label matches if told so or we have no description 359 | if (preferLabelMatches || !description) { 360 | const [labelScore, labelPositions] = scoreFuzzy( 361 | label, 362 | query.normalized, 363 | query.normalizedLowercase, 364 | allowNonContiguousMatches && !query.expectContiguousMatch, 365 | ); 366 | if (labelScore) { 367 | // If we have a prefix match on the label, we give a much 368 | // higher baseScore to elevate these matches over others 369 | // This ensures that typing a file name wins over results 370 | // that are present somewhere in the label, but not the 371 | // beginning. 372 | const labelPrefixMatch = matchesPrefix(true, query.normalized, label); 373 | let baseScore: number; 374 | if (labelPrefixMatch) { 375 | baseScore = LABEL_PREFIX_SCORE_THRESHOLD; 376 | 377 | // We give another boost to labels that are short, e.g. given 378 | // files "window.ts" and "windowActions.ts" and a query of 379 | // "window", we want "window.ts" to receive a higher score. 380 | // As such we compute the percentage the query has within the 381 | // label and add that to the baseScore. 382 | const prefixLengthBoost = Math.round((query.normalized.length / label.length) * 100); 383 | baseScore += prefixLengthBoost; 384 | } else { 385 | baseScore = LABEL_SCORE_THRESHOLD; 386 | } 387 | 388 | return integrateValueScores( 389 | { 390 | score: baseScore + labelScore, 391 | labelMatch: labelPrefixMatch || createMatches(labelPositions), 392 | label, 393 | description, 394 | rawValue, 395 | formattedValue, 396 | }, 397 | rawValue, 398 | formattedValue, 399 | query, 400 | allowNonContiguousMatches, 401 | ); 402 | } 403 | } 404 | 405 | // Finally compute description + label scores if we have a description 406 | if (description) { 407 | let descriptionPrefix = description; 408 | 409 | if (path) { 410 | descriptionPrefix = `${description}.`; // assume this is a file path 411 | } 412 | 413 | const descriptionPrefixLength = descriptionPrefix.length; 414 | const descriptionAndLabel = `${descriptionPrefix}${label}`; 415 | 416 | const [labelDescriptionScore, labelDescriptionPositions] = scoreFuzzy( 417 | descriptionAndLabel, 418 | query.normalized, 419 | query.normalizedLowercase, 420 | allowNonContiguousMatches && !query.expectContiguousMatch, 421 | ); 422 | if (labelDescriptionScore) { 423 | const labelDescriptionMatches = createMatches(labelDescriptionPositions); 424 | const labelMatch: Match[] = []; 425 | const descriptionMatch: Match[] = []; 426 | 427 | // We have to split the matches back onto the label and description portions 428 | labelDescriptionMatches.forEach((h) => { 429 | // Match overlaps label and description part, we need to split it up 430 | if (h.start < descriptionPrefixLength && h.end > descriptionPrefixLength) { 431 | labelMatch.push({ start: 0, end: h.end - descriptionPrefixLength }); 432 | descriptionMatch.push({ start: h.start, end: descriptionPrefixLength }); 433 | } 434 | 435 | // Match on label part 436 | else if (h.start >= descriptionPrefixLength) { 437 | labelMatch.push({ 438 | start: h.start - descriptionPrefixLength, 439 | end: h.end - descriptionPrefixLength, 440 | }); 441 | } 442 | 443 | // Match on description part 444 | else { 445 | descriptionMatch.push(h); 446 | } 447 | }); 448 | 449 | return integrateValueScores( 450 | { 451 | score: labelDescriptionScore, 452 | labelMatch, 453 | descriptionMatch, 454 | label, 455 | description, 456 | rawValue, 457 | formattedValue, 458 | }, 459 | rawValue, 460 | formattedValue, 461 | query, 462 | allowNonContiguousMatches, 463 | ); 464 | } 465 | } 466 | 467 | return integrateValueScores( 468 | { 469 | score: 0, 470 | label, 471 | description, 472 | rawValue, 473 | formattedValue, 474 | }, 475 | rawValue, 476 | formattedValue, 477 | query, 478 | allowNonContiguousMatches, 479 | ); 480 | } 481 | 482 | export function compareItemsByFuzzyScore( 483 | itemA: T, 484 | itemB: T, 485 | query: PreparedQuery, 486 | allowNonContiguousMatches: boolean, 487 | accessor: IItemAccessor, 488 | cache = new Map(), 489 | ): number { 490 | const itemScoreA = scoreItemFuzzy(itemA, query, allowNonContiguousMatches, accessor, cache); 491 | const itemScoreB = scoreItemFuzzy(itemB, query, allowNonContiguousMatches, accessor, cache); 492 | 493 | const scoreA = itemScoreA.score; 494 | const scoreB = itemScoreB.score; 495 | 496 | // 1.) identity matches have highest score 497 | if (scoreA === PATH_IDENTITY_SCORE || scoreB === PATH_IDENTITY_SCORE) { 498 | if (scoreA !== scoreB) { 499 | return scoreA === PATH_IDENTITY_SCORE ? -1 : 1; 500 | } 501 | } 502 | 503 | // 2.) matches on label are considered higher compared to label+description matches 504 | if (scoreA > LABEL_SCORE_THRESHOLD || scoreB > LABEL_SCORE_THRESHOLD) { 505 | if (scoreA !== scoreB) { 506 | return scoreA > scoreB ? -1 : 1; 507 | } 508 | 509 | // prefer more compact matches over longer in label (unless this is a prefix match where 510 | // longer prefix matches are actually preferred) 511 | if (scoreA < LABEL_PREFIX_SCORE_THRESHOLD && scoreB < LABEL_PREFIX_SCORE_THRESHOLD) { 512 | const comparedByMatchLength = compareByMatchLength( 513 | itemScoreA.labelMatch, 514 | itemScoreB.labelMatch, 515 | ); 516 | if (comparedByMatchLength !== 0) { 517 | return comparedByMatchLength; 518 | } 519 | } 520 | 521 | // prefer shorter labels over longer labels 522 | const labelA = accessor.getItemLabel(itemA) || ""; 523 | const labelB = accessor.getItemLabel(itemB) || ""; 524 | if (labelA.length !== labelB.length) { 525 | return labelA.length - labelB.length; 526 | } 527 | } 528 | 529 | // 3.) compare by score 530 | if (scoreA !== scoreB) { 531 | return scoreA > scoreB ? -1 : 1; 532 | } 533 | 534 | const itemAIsArrayItem = accessor.getIsArrayItem(itemA); 535 | const itemBIsArrayItem = accessor.getIsArrayItem(itemB); 536 | 537 | // 4.) prefer non array items over array items 538 | if (itemAIsArrayItem !== itemBIsArrayItem) { 539 | return itemBIsArrayItem ? -1 : 1; 540 | } 541 | 542 | return fallbackCompare(itemA, itemB, query, accessor); 543 | } 544 | 545 | function compareByMatchLength(matchesA?: Match[], matchesB?: Match[]): number { 546 | if ( 547 | (!matchesA && !matchesB) || 548 | ((!matchesA || !matchesA.length) && (!matchesB || !matchesB.length)) 549 | ) { 550 | return 0; // make sure to not cause bad comparing when matches are not provided 551 | } 552 | 553 | if (!matchesB || !matchesB.length) { 554 | return -1; 555 | } 556 | 557 | if (!matchesA || !matchesA.length) { 558 | return 1; 559 | } 560 | 561 | // Compute match length of A (first to last match) 562 | const matchStartA = matchesA[0].start; 563 | const matchEndA = matchesA[matchesA.length - 1].end; 564 | const matchLengthA = matchEndA - matchStartA; 565 | 566 | // Compute match length of B (first to last match) 567 | const matchStartB = matchesB[0].start; 568 | const matchEndB = matchesB[matchesB.length - 1].end; 569 | const matchLengthB = matchEndB - matchStartB; 570 | 571 | // Prefer shorter match length 572 | return matchLengthA === matchLengthB ? 0 : matchLengthB < matchLengthA ? 1 : -1; 573 | } 574 | 575 | function fallbackCompare( 576 | itemA: T, 577 | itemB: T, 578 | query: PreparedQuery, 579 | accessor: IItemAccessor, 580 | ): number { 581 | // check for label + description length and prefer shorter 582 | const labelA = accessor.getItemLabel(itemA) || ""; 583 | const labelB = accessor.getItemLabel(itemB) || ""; 584 | 585 | const descriptionA = accessor.getItemDescription(itemA); 586 | const descriptionB = accessor.getItemDescription(itemB); 587 | 588 | const labelDescriptionALength = labelA.length + (descriptionA ? descriptionA.length : 0); 589 | const labelDescriptionBLength = labelB.length + (descriptionB ? descriptionB.length : 0); 590 | 591 | if (labelDescriptionALength !== labelDescriptionBLength) { 592 | return labelDescriptionALength - labelDescriptionBLength; 593 | } 594 | 595 | // check for path length and prefer shorter 596 | const pathA = accessor.getItemPath(itemA); 597 | const pathB = accessor.getItemPath(itemB); 598 | 599 | if (pathA && pathB && pathA.length !== pathB.length) { 600 | return pathA.length - pathB.length; 601 | } 602 | 603 | // 7.) finally we have equal scores and equal length, we fallback to comparer 604 | 605 | // compare by label 606 | if (labelA !== labelB) { 607 | return compareAnything(labelA, labelB, query.normalized); 608 | } 609 | 610 | // compare by description 611 | if (descriptionA && descriptionB && descriptionA !== descriptionB) { 612 | return compareAnything(descriptionA, descriptionB, query.normalized); 613 | } 614 | 615 | // compare by path 616 | if (pathA && pathB && pathA !== pathB) { 617 | return compareAnything(pathA, pathB, query.normalized); 618 | } 619 | 620 | // equal 621 | return 0; 622 | } 623 | 624 | export function compareAnything(one: string, other: string, lookFor: string): number { 625 | const elementAName = one.toLowerCase(); 626 | const elementBName = other.toLowerCase(); 627 | 628 | // Sort prefix matches over non prefix matches 629 | const prefixCompare = compareByPrefix(one, other, lookFor); 630 | if (prefixCompare) { 631 | return prefixCompare; 632 | } 633 | 634 | // Sort suffix matches over non suffix matches 635 | const elementASuffixMatch = elementAName.endsWith(lookFor); 636 | const elementBSuffixMatch = elementBName.endsWith(lookFor); 637 | if (elementASuffixMatch !== elementBSuffixMatch) { 638 | return elementASuffixMatch ? -1 : 1; 639 | } 640 | 641 | // Compare by name 642 | return elementAName.localeCompare(elementBName); 643 | } 644 | 645 | export function compareByPrefix(one: string, other: string, lookFor: string): number { 646 | const elementAName = one.toLowerCase(); 647 | const elementBName = other.toLowerCase(); 648 | 649 | // Sort prefix matches over non prefix matches 650 | const elementAPrefixMatch = elementAName.startsWith(lookFor); 651 | const elementBPrefixMatch = elementBName.startsWith(lookFor); 652 | if (elementAPrefixMatch !== elementBPrefixMatch) { 653 | return elementAPrefixMatch ? -1 : 1; 654 | } 655 | 656 | // Same prefix: Sort shorter matches to the top to have those on top that match more precisely 657 | else if (elementAPrefixMatch && elementBPrefixMatch) { 658 | if (elementAName.length < elementBName.length) { 659 | return -1; 660 | } 661 | 662 | if (elementAName.length > elementBName.length) { 663 | return 1; 664 | } 665 | } 666 | 667 | return 0; 668 | } 669 | 670 | function integrateValueScores( 671 | score: ItemScore, 672 | rawValue: string | undefined, 673 | formattedValue: string | undefined, 674 | query: PreparedQueryPiece, 675 | allowNonContiguousMatches: boolean, 676 | ): ItemScore { 677 | const result: ItemScore = { 678 | ...score, 679 | }; 680 | 681 | if (rawValue) { 682 | const [rawValueScore, rawValuePositions] = scoreFuzzy( 683 | rawValue, 684 | query.normalized, 685 | query.normalizedLowercase, 686 | allowNonContiguousMatches && !query.expectContiguousMatch, 687 | false, 688 | ); 689 | 690 | if (rawValueScore) { 691 | const rawValueMatch = createMatches(rawValuePositions); 692 | 693 | result.score = result.score + rawValueScore; 694 | result.rawValueMatch = rawValueMatch; 695 | } 696 | } 697 | 698 | if (formattedValue && rawValue !== formattedValue) { 699 | const [formattedValueScore, formattedValuePositions] = scoreFuzzy( 700 | formattedValue, 701 | query.normalized, 702 | query.normalizedLowercase, 703 | allowNonContiguousMatches && !query.expectContiguousMatch, 704 | false, 705 | ); 706 | 707 | if (formattedValueScore) { 708 | const formattedValueMatch = createMatches(formattedValuePositions); 709 | 710 | result.score = result.score + formattedValueScore; 711 | result.formattedValueMatch = formattedValueMatch; 712 | } 713 | } 714 | 715 | return result; 716 | } 717 | 718 | function createMatches(offsets: number[] | undefined): Match[] { 719 | const ret: Match[] = []; 720 | if (!offsets) { 721 | return ret; 722 | } 723 | 724 | let last: Match | undefined; 725 | for (const pos of offsets) { 726 | if (last && last.end === pos) { 727 | last.end += 1; 728 | } else { 729 | last = { start: pos, end: pos + 1 }; 730 | ret.push(last); 731 | } 732 | } 733 | 734 | return ret; 735 | } 736 | 737 | function matchesPrefix( 738 | ignoreCase: boolean, 739 | word: string, 740 | wordToMatchAgainst: string, 741 | ): Match[] | null { 742 | if (!wordToMatchAgainst || wordToMatchAgainst.length < word.length) { 743 | return null; 744 | } 745 | 746 | let matches: boolean; 747 | if (ignoreCase) { 748 | matches = strings.startsWithIgnoreCase(wordToMatchAgainst, word); 749 | } else { 750 | matches = wordToMatchAgainst.indexOf(word) === 0; 751 | } 752 | 753 | if (!matches) { 754 | return null; 755 | } 756 | 757 | return word.length > 0 ? [{ start: 0, end: word.length }] : []; 758 | } 759 | 760 | export function normalizeMatches(matches: Match[]): Match[] { 761 | // sort matches by start to be able to normalize 762 | const sortedMatches = matches.sort((matchA, matchB) => { 763 | return matchA.start - matchB.start; 764 | }); 765 | 766 | // merge matches that overlap 767 | const normalizedMatches: Match[] = []; 768 | let currentMatch: Match | undefined = undefined; 769 | for (const match of sortedMatches) { 770 | // if we have no current match or the matches 771 | // do not overlap, we take it as is and remember 772 | // it for future merging 773 | if (!currentMatch || !matchOverlaps(currentMatch, match)) { 774 | currentMatch = match; 775 | normalizedMatches.push(match); 776 | } 777 | 778 | // otherwise we merge the matches 779 | else { 780 | currentMatch.start = Math.min(currentMatch.start, match.start); 781 | currentMatch.end = Math.max(currentMatch.end, match.end); 782 | } 783 | } 784 | 785 | return normalizedMatches; 786 | } 787 | 788 | function matchOverlaps(matchA: Match, matchB: Match): boolean { 789 | if (matchA.end < matchB.start) { 790 | return false; // A ends before B starts 791 | } 792 | 793 | if (matchB.end < matchA.start) { 794 | return false; // B ends before A starts 795 | } 796 | 797 | return true; 798 | } 799 | 800 | function getCacheHash( 801 | label: string, 802 | description: string | undefined, 803 | path: string | undefined, 804 | rawValue: string | undefined, 805 | formattedValue: string | undefined, 806 | allowNonContiguousMatches: boolean, 807 | query: PreparedQuery, 808 | ): number { 809 | const values = query.values ? query.values : [query]; 810 | const cacheHash = hash({ 811 | [query.normalized]: { 812 | values: values.map((v) => ({ 813 | value: v.normalized, 814 | expectContiguousMatch: v.expectContiguousMatch, 815 | })), 816 | label, 817 | description, 818 | allowNonContiguousMatches, 819 | path, 820 | rawValue, 821 | formattedValue, 822 | }, 823 | }); 824 | return cacheHash; 825 | } 826 | -------------------------------------------------------------------------------- /src/hash.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as strings from "./strings"; 7 | 8 | /** 9 | * Return a hash value for an object. 10 | */ 11 | export function hash(obj: any): number { 12 | return doHash(obj, 0); 13 | } 14 | 15 | export function doHash(obj: any, hashVal: number): number { 16 | switch (typeof obj) { 17 | case "object": 18 | if (obj === null) { 19 | return numberHash(349, hashVal); 20 | } else if (Array.isArray(obj)) { 21 | return arrayHash(obj, hashVal); 22 | } 23 | return objectHash(obj, hashVal); 24 | case "string": 25 | return stringHash(obj, hashVal); 26 | case "boolean": 27 | return booleanHash(obj, hashVal); 28 | case "number": 29 | return numberHash(obj, hashVal); 30 | case "undefined": 31 | return numberHash(937, hashVal); 32 | default: 33 | return numberHash(617, hashVal); 34 | } 35 | } 36 | 37 | export function numberHash(val: number, initialHashVal: number): number { 38 | return ((initialHashVal << 5) - initialHashVal + val) | 0; // hashVal * 31 + ch, keep as int32 39 | } 40 | 41 | function booleanHash(b: boolean, initialHashVal: number): number { 42 | return numberHash(b ? 433 : 863, initialHashVal); 43 | } 44 | 45 | export function stringHash(s: string, hashVal: number) { 46 | hashVal = numberHash(149417, hashVal); 47 | for (let i = 0, length = s.length; i < length; i++) { 48 | hashVal = numberHash(s.charCodeAt(i), hashVal); 49 | } 50 | return hashVal; 51 | } 52 | 53 | function arrayHash(arr: any[], initialHashVal: number): number { 54 | initialHashVal = numberHash(104579, initialHashVal); 55 | return arr.reduce((hashVal, item) => doHash(item, hashVal), initialHashVal); 56 | } 57 | 58 | function objectHash(obj: any, initialHashVal: number): number { 59 | initialHashVal = numberHash(181387, initialHashVal); 60 | return Object.keys(obj) 61 | .sort() 62 | .reduce((hashVal, key) => { 63 | hashVal = stringHash(key, hashVal); 64 | return doHash(obj[key], hashVal); 65 | }, initialHashVal); 66 | } 67 | 68 | export class Hasher { 69 | private _value = 0; 70 | 71 | get value(): number { 72 | return this._value; 73 | } 74 | 75 | hash(obj: any): number { 76 | this._value = doHash(obj, this._value); 77 | return this._value; 78 | } 79 | } 80 | 81 | const enum SHA1Constant { 82 | BLOCK_SIZE = 64, // 512 / 8 83 | UNICODE_REPLACEMENT = 0xfffd, 84 | } 85 | 86 | function leftRotate(value: number, bits: number, totalBits = 32): number { 87 | // delta + bits = totalBits 88 | const delta = totalBits - bits; 89 | 90 | // All ones, expect `delta` zeros aligned to the right 91 | const mask = ~((1 << delta) - 1); 92 | 93 | // Join (value left-shifted `bits` bits) with (masked value right-shifted `delta` bits) 94 | return ((value << bits) | ((mask & value) >>> delta)) >>> 0; 95 | } 96 | 97 | function fill(dest: Uint8Array, index = 0, count: number = dest.byteLength, value = 0): void { 98 | for (let i = 0; i < count; i++) { 99 | dest[index + i] = value; 100 | } 101 | } 102 | 103 | function leftPad(value: string, length: number, char = "0"): string { 104 | while (value.length < length) { 105 | value = char + value; 106 | } 107 | return value; 108 | } 109 | 110 | export function toHexString(buffer: ArrayBuffer): string; 111 | export function toHexString(value: number, bitsize?: number): string; 112 | export function toHexString(bufferOrValue: ArrayBuffer | number, bitsize = 32): string { 113 | if (bufferOrValue instanceof ArrayBuffer) { 114 | return Array.from(new Uint8Array(bufferOrValue)) 115 | .map((b) => b.toString(16).padStart(2, "0")) 116 | .join(""); 117 | } 118 | 119 | return leftPad((bufferOrValue >>> 0).toString(16), bitsize / 4); 120 | } 121 | 122 | /** 123 | * A SHA1 implementation that works with strings and does not allocate. 124 | */ 125 | export class StringSHA1 { 126 | private static _bigBlock32 = new DataView(new ArrayBuffer(320)); // 80 * 4 = 320 127 | 128 | private _h0 = 0x67452301; 129 | private _h1 = 0xefcdab89; 130 | private _h2 = 0x98badcfe; 131 | private _h3 = 0x10325476; 132 | private _h4 = 0xc3d2e1f0; 133 | 134 | private readonly _buff: Uint8Array; 135 | private readonly _buffDV: DataView; 136 | private _buffLen: number; 137 | private _totalLen: number; 138 | private _leftoverHighSurrogate: number; 139 | private _finished: boolean; 140 | 141 | constructor() { 142 | this._buff = new Uint8Array(SHA1Constant.BLOCK_SIZE + 3 /* to fit any utf-8 */); 143 | this._buffDV = new DataView(this._buff.buffer); 144 | this._buffLen = 0; 145 | this._totalLen = 0; 146 | this._leftoverHighSurrogate = 0; 147 | this._finished = false; 148 | } 149 | 150 | public update(str: string): void { 151 | const strLen = str.length; 152 | if (strLen === 0) { 153 | return; 154 | } 155 | 156 | const buff = this._buff; 157 | let buffLen = this._buffLen; 158 | let leftoverHighSurrogate = this._leftoverHighSurrogate; 159 | let charCode: number; 160 | let offset: number; 161 | 162 | if (leftoverHighSurrogate !== 0) { 163 | charCode = leftoverHighSurrogate; 164 | offset = -1; 165 | leftoverHighSurrogate = 0; 166 | } else { 167 | charCode = str.charCodeAt(0); 168 | offset = 0; 169 | } 170 | 171 | // eslint-disable-next-line no-constant-condition 172 | while (true) { 173 | let codePoint = charCode; 174 | if (strings.isHighSurrogate(charCode)) { 175 | if (offset + 1 < strLen) { 176 | const nextCharCode = str.charCodeAt(offset + 1); 177 | if (strings.isLowSurrogate(nextCharCode)) { 178 | offset++; 179 | codePoint = strings.computeCodePoint(charCode, nextCharCode); 180 | } else { 181 | // illegal => unicode replacement character 182 | codePoint = SHA1Constant.UNICODE_REPLACEMENT; 183 | } 184 | } else { 185 | // last character is a surrogate pair 186 | leftoverHighSurrogate = charCode; 187 | break; 188 | } 189 | } else if (strings.isLowSurrogate(charCode)) { 190 | // illegal => unicode replacement character 191 | codePoint = SHA1Constant.UNICODE_REPLACEMENT; 192 | } 193 | 194 | buffLen = this._push(buff, buffLen, codePoint); 195 | offset++; 196 | if (offset < strLen) { 197 | charCode = str.charCodeAt(offset); 198 | } else { 199 | break; 200 | } 201 | } 202 | 203 | this._buffLen = buffLen; 204 | this._leftoverHighSurrogate = leftoverHighSurrogate; 205 | } 206 | 207 | private _push(buff: Uint8Array, buffLen: number, codePoint: number): number { 208 | if (codePoint < 0x0080) { 209 | buff[buffLen++] = codePoint; 210 | } else if (codePoint < 0x0800) { 211 | buff[buffLen++] = 0b11000000 | ((codePoint & 0b00000000000000000000011111000000) >>> 6); 212 | buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); 213 | } else if (codePoint < 0x10000) { 214 | buff[buffLen++] = 0b11100000 | ((codePoint & 0b00000000000000001111000000000000) >>> 12); 215 | buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000111111000000) >>> 6); 216 | buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); 217 | } else { 218 | buff[buffLen++] = 0b11110000 | ((codePoint & 0b00000000000111000000000000000000) >>> 18); 219 | buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000111111000000000000) >>> 12); 220 | buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000111111000000) >>> 6); 221 | buff[buffLen++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); 222 | } 223 | 224 | if (buffLen >= SHA1Constant.BLOCK_SIZE) { 225 | this._step(); 226 | buffLen -= SHA1Constant.BLOCK_SIZE; 227 | this._totalLen += SHA1Constant.BLOCK_SIZE; 228 | // take last 3 in case of UTF8 overflow 229 | buff[0] = buff[SHA1Constant.BLOCK_SIZE + 0]; 230 | buff[1] = buff[SHA1Constant.BLOCK_SIZE + 1]; 231 | buff[2] = buff[SHA1Constant.BLOCK_SIZE + 2]; 232 | } 233 | 234 | return buffLen; 235 | } 236 | 237 | public digest(): string { 238 | if (!this._finished) { 239 | this._finished = true; 240 | if (this._leftoverHighSurrogate) { 241 | // illegal => unicode replacement character 242 | this._leftoverHighSurrogate = 0; 243 | this._buffLen = this._push(this._buff, this._buffLen, SHA1Constant.UNICODE_REPLACEMENT); 244 | } 245 | this._totalLen += this._buffLen; 246 | this._wrapUp(); 247 | } 248 | 249 | return ( 250 | toHexString(this._h0) + 251 | toHexString(this._h1) + 252 | toHexString(this._h2) + 253 | toHexString(this._h3) + 254 | toHexString(this._h4) 255 | ); 256 | } 257 | 258 | private _wrapUp(): void { 259 | this._buff[this._buffLen++] = 0x80; 260 | fill(this._buff, this._buffLen); 261 | 262 | if (this._buffLen > 56) { 263 | this._step(); 264 | fill(this._buff); 265 | } 266 | 267 | // this will fit because the mantissa can cover up to 52 bits 268 | const ml = 8 * this._totalLen; 269 | 270 | this._buffDV.setUint32(56, Math.floor(ml / 4294967296), false); 271 | this._buffDV.setUint32(60, ml % 4294967296, false); 272 | 273 | this._step(); 274 | } 275 | 276 | private _step(): void { 277 | const bigBlock32 = StringSHA1._bigBlock32; 278 | const data = this._buffDV; 279 | 280 | for (let j = 0; j < 64 /* 16*4 */; j += 4) { 281 | bigBlock32.setUint32(j, data.getUint32(j, false), false); 282 | } 283 | 284 | for (let j = 64; j < 320 /* 80*4 */; j += 4) { 285 | bigBlock32.setUint32( 286 | j, 287 | leftRotate( 288 | bigBlock32.getUint32(j - 12, false) ^ 289 | bigBlock32.getUint32(j - 32, false) ^ 290 | bigBlock32.getUint32(j - 56, false) ^ 291 | bigBlock32.getUint32(j - 64, false), 292 | 1, 293 | ), 294 | false, 295 | ); 296 | } 297 | 298 | let a = this._h0; 299 | let b = this._h1; 300 | let c = this._h2; 301 | let d = this._h3; 302 | let e = this._h4; 303 | 304 | let f: number, k: number; 305 | let temp: number; 306 | 307 | for (let j = 0; j < 80; j++) { 308 | if (j < 20) { 309 | f = (b & c) | (~b & d); 310 | k = 0x5a827999; 311 | } else if (j < 40) { 312 | f = b ^ c ^ d; 313 | k = 0x6ed9eba1; 314 | } else if (j < 60) { 315 | f = (b & c) | (b & d) | (c & d); 316 | k = 0x8f1bbcdc; 317 | } else { 318 | f = b ^ c ^ d; 319 | k = 0xca62c1d6; 320 | } 321 | 322 | temp = (leftRotate(a, 5) + f + e + k + bigBlock32.getUint32(j * 4, false)) & 0xffffffff; 323 | e = d; 324 | d = c; 325 | c = leftRotate(b, 30); 326 | b = a; 327 | a = temp; 328 | } 329 | 330 | this._h0 = (this._h0 + a) & 0xffffffff; 331 | this._h1 = (this._h1 + b) & 0xffffffff; 332 | this._h2 = (this._h2 + c) & 0xffffffff; 333 | this._h3 = (this._h3 + d) & 0xffffffff; 334 | this._h4 = (this._h4 + e) & 0xffffffff; 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | JSONHeroSearch, 3 | JSONHeroSearchOptions, 4 | JSONHeroSearchAccessor, 5 | JSONHeroSearchFormatter, 6 | } from "./JSONHeroSearch"; 7 | export { search, SearchResult } from "./search"; 8 | export { ItemScore, scoreFuzzy } from "./fuzzyScoring"; 9 | -------------------------------------------------------------------------------- /src/scoring.ts: -------------------------------------------------------------------------------- 1 | import { CharCode } from "./strings"; 2 | 3 | export type FuzzyScore = [number /* score */, number[] /* match positions */]; 4 | 5 | const NO_MATCH = 0; 6 | const NO_SCORE: FuzzyScore = [NO_MATCH, []]; 7 | 8 | // export type JsonItem = { 9 | // label: string; 10 | // path: string; 11 | // isContainer: boolean; 12 | // rawValue?: string; 13 | // formattedValue?: string; 14 | // }; 15 | export interface IItemAccessor { 16 | /** 17 | * Just the label of the item to score on. 18 | */ 19 | getItemLabel(item: T): string | undefined; 20 | 21 | /** 22 | * The optional description of the item to score on. 23 | */ 24 | getItemDescription(item: T): string | undefined; 25 | getItemPath(item: T): string | undefined; 26 | getRawValue(item: T): string | undefined; 27 | getFormattedValue(item: T): string | undefined; 28 | getIsArrayItem(item: T): boolean; 29 | } 30 | 31 | export function scoreFuzzy( 32 | target: string, 33 | query: string, 34 | queryLower: string, 35 | allowNonContiguousMatches: boolean, 36 | optimizeForPaths = true, 37 | ): FuzzyScore { 38 | if (!target || !query) { 39 | return NO_SCORE; // return early if target or query are undefined 40 | } 41 | 42 | const targetLength = target.length; 43 | const queryLength = query.length; 44 | 45 | if (targetLength < queryLength) { 46 | return NO_SCORE; // impossible for query to be contained in target 47 | } 48 | 49 | // if (DEBUG) { 50 | // console.group(`Target: ${target}, Query: ${query}`); 51 | // } 52 | 53 | const targetLower = target.toLowerCase(); 54 | const res = doScoreFuzzy( 55 | query, 56 | queryLower, 57 | queryLength, 58 | target, 59 | targetLower, 60 | targetLength, 61 | allowNonContiguousMatches, 62 | optimizeForPaths, 63 | ); 64 | 65 | // if (DEBUG) { 66 | // console.log(`%cFinal Score: ${res[0]}`, 'font-weight: bold'); 67 | // console.groupEnd(); 68 | // } 69 | 70 | return res; 71 | } 72 | 73 | function doScoreFuzzy( 74 | query: string, 75 | queryLower: string, 76 | queryLength: number, 77 | target: string, 78 | targetLower: string, 79 | targetLength: number, 80 | allowNonContiguousMatches: boolean, 81 | optimizeForPaths: boolean, 82 | ): FuzzyScore { 83 | const scores: number[] = []; 84 | const matches: number[] = []; 85 | 86 | // 87 | // Build Scorer Matrix: 88 | // 89 | // The matrix is composed of query q and target t. For each index we score 90 | // q[i] with t[i] and compare that with the previous score. If the score is 91 | // equal or larger, we keep the match. In addition to the score, we also keep 92 | // the length of the consecutive matches to use as boost for the score. 93 | // 94 | // t a r g e t 95 | // q 96 | // u 97 | // e 98 | // r 99 | // y 100 | // 101 | for (let queryIndex = 0; queryIndex < queryLength; queryIndex++) { 102 | const queryIndexOffset = queryIndex * targetLength; 103 | const queryIndexPreviousOffset = queryIndexOffset - targetLength; 104 | 105 | const queryIndexGtNull = queryIndex > 0; 106 | 107 | const queryCharAtIndex = query[queryIndex]; 108 | const queryLowerCharAtIndex = queryLower[queryIndex]; 109 | 110 | for (let targetIndex = 0; targetIndex < targetLength; targetIndex++) { 111 | const targetIndexGtNull = targetIndex > 0; 112 | 113 | const currentIndex = queryIndexOffset + targetIndex; 114 | const leftIndex = currentIndex - 1; 115 | const diagIndex = queryIndexPreviousOffset + targetIndex - 1; 116 | 117 | const leftScore = targetIndexGtNull ? scores[leftIndex] : 0; 118 | const diagScore = queryIndexGtNull && targetIndexGtNull ? scores[diagIndex] : 0; 119 | 120 | const matchesSequenceLength = queryIndexGtNull && targetIndexGtNull ? matches[diagIndex] : 0; 121 | 122 | // If we are not matching on the first query character any more, we only produce a 123 | // score if we had a score previously for the last query index (by looking at the diagScore). 124 | // This makes sure that the query always matches in sequence on the target. For example 125 | // given a target of "ede" and a query of "de", we would otherwise produce a wrong high score 126 | // for query[1] ("e") matching on target[0] ("e") because of the "beginning of word" boost. 127 | let score: number; 128 | if (!diagScore && queryIndexGtNull) { 129 | score = 0; 130 | } else { 131 | score = computeCharScore( 132 | queryCharAtIndex, 133 | queryLowerCharAtIndex, 134 | target, 135 | targetLower, 136 | targetIndex, 137 | matchesSequenceLength, 138 | optimizeForPaths, 139 | ); 140 | } 141 | 142 | // We have a score and its equal or larger than the left score 143 | // Match: sequence continues growing from previous diag value 144 | // Score: increases by diag score value 145 | const isValidScore = score && diagScore + score >= leftScore; 146 | if ( 147 | isValidScore && 148 | // We don't need to check if it's contiguous if we allow non-contiguous matches 149 | (allowNonContiguousMatches || 150 | // We must be looking for a contiguous match. 151 | // Looking at an index higher than 0 in the query means we must have already 152 | // found out this is contiguous otherwise there wouldn't have been a score 153 | queryIndexGtNull || 154 | // lastly check if the query is completely contiguous at this index in the target 155 | targetLower.startsWith(queryLower, targetIndex)) 156 | ) { 157 | matches[currentIndex] = matchesSequenceLength + 1; 158 | scores[currentIndex] = diagScore + score; 159 | } 160 | 161 | // We either have no score or the score is lower than the left score 162 | // Match: reset to 0 163 | // Score: pick up from left hand side 164 | else { 165 | matches[currentIndex] = NO_MATCH; 166 | scores[currentIndex] = leftScore; 167 | } 168 | } 169 | } 170 | 171 | // Restore Positions (starting from bottom right of matrix) 172 | const positions: number[] = []; 173 | let queryIndex = queryLength - 1; 174 | let targetIndex = targetLength - 1; 175 | while (queryIndex >= 0 && targetIndex >= 0) { 176 | const currentIndex = queryIndex * targetLength + targetIndex; 177 | const match = matches[currentIndex]; 178 | if (match === NO_MATCH) { 179 | targetIndex--; // go left 180 | } else { 181 | positions.push(targetIndex); 182 | 183 | // go up and left 184 | queryIndex--; 185 | targetIndex--; 186 | } 187 | } 188 | 189 | // Print matrix 190 | // if (DEBUG_MATRIX) { 191 | // printMatrix(query, target, matches, scores); 192 | // } 193 | 194 | return [scores[queryLength * targetLength - 1], positions.reverse()]; 195 | } 196 | 197 | function computeCharScore( 198 | queryCharAtIndex: string, 199 | queryLowerCharAtIndex: string, 200 | target: string, 201 | targetLower: string, 202 | targetIndex: number, 203 | matchesSequenceLength: number, 204 | optimizeForPaths: boolean, 205 | ): number { 206 | let score = 0; 207 | 208 | if (!considerAsEqual(queryLowerCharAtIndex, targetLower[targetIndex])) { 209 | return score; // no match of characters 210 | } 211 | 212 | // Character match bonus 213 | score += 1; 214 | 215 | // if (DEBUG) { 216 | // console.groupCollapsed(`%cCharacter match bonus: +1 (char: ${queryLowerCharAtIndex} at index ${targetIndex}, total score: ${score})`, 'font-weight: normal'); 217 | // } 218 | 219 | // Consecutive match bonus 220 | if (matchesSequenceLength > 0) { 221 | score += matchesSequenceLength * 5; 222 | 223 | // if (DEBUG) { 224 | // console.log(`Consecutive match bonus: +${matchesSequenceLength * 5}`); 225 | // } 226 | } 227 | 228 | // Same case bonus 229 | if (queryCharAtIndex === target[targetIndex]) { 230 | score += 1; 231 | 232 | // if (DEBUG) { 233 | // console.log('Same case bonus: +1'); 234 | // } 235 | } 236 | 237 | // Start of word bonus 238 | if (targetIndex === 0) { 239 | score += 8; 240 | 241 | // if (DEBUG) { 242 | // console.log('Start of word bonus: +8'); 243 | // } 244 | } else { 245 | // After separator bonus 246 | const separatorBonus = scoreSeparatorAtPos(target.charCodeAt(targetIndex - 1)); 247 | if (separatorBonus && optimizeForPaths) { 248 | score += separatorBonus; 249 | 250 | // if (DEBUG) { 251 | // console.log(`After separator bonus: +${separatorBonus}`); 252 | // } 253 | } 254 | 255 | // Inside word upper case bonus (camel case). We only give this bonus if we're not in a contiguous sequence. 256 | // For example: 257 | // NPE => NullPointerException = boost 258 | // HTTP => HTTP = not boost 259 | else if (isUpper(target.charCodeAt(targetIndex)) && matchesSequenceLength === 0) { 260 | score += 2; 261 | 262 | // if (DEBUG) { 263 | // console.log('Inside word upper case bonus: +2'); 264 | // } 265 | } 266 | } 267 | 268 | // if (DEBUG) { 269 | // console.groupEnd(); 270 | // } 271 | 272 | return score; 273 | } 274 | 275 | function considerAsEqual(a: string, b: string): boolean { 276 | if (a === b) { 277 | return true; 278 | } 279 | 280 | // Special case path separators: ignore platform differences 281 | if (a === ".") { 282 | return b === "."; 283 | } 284 | 285 | return false; 286 | } 287 | 288 | function scoreSeparatorAtPos(charCode: number): number { 289 | switch (charCode) { 290 | // prefer path separators... 291 | case CharCode.Period: 292 | return 5; 293 | case CharCode.Slash: 294 | case CharCode.Backslash: 295 | case CharCode.Underline: 296 | case CharCode.Dash: 297 | case CharCode.Space: 298 | case CharCode.SingleQuote: 299 | case CharCode.DoubleQuote: 300 | case CharCode.Colon: 301 | return 4; // ...over other separators 302 | default: 303 | return 0; 304 | } 305 | } 306 | 307 | export function isUpper(code: number): boolean { 308 | return CharCode.A <= code && code <= CharCode.Z; 309 | } 310 | -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- 1 | import { 2 | compareItemsByFuzzyScore, 3 | IItemAccessor, 4 | ItemScore, 5 | PreparedQuery, 6 | scoreItemFuzzy, 7 | } from "./fuzzyScoring"; 8 | 9 | export type SearchResult = { 10 | item: T; 11 | score: ItemScore; 12 | }; 13 | 14 | export function search( 15 | items: T[], 16 | query: PreparedQuery, 17 | allowNonContiguousMatches: boolean, 18 | accessor: IItemAccessor, 19 | cache = new Map(), 20 | ): Array> { 21 | const sortedItems = [...items].sort((a, b) => 22 | compareItemsByFuzzyScore(a, b, query, allowNonContiguousMatches, accessor, cache), 23 | ); 24 | 25 | const allResults = sortedItems.map((item) => { 26 | const score = scoreItemFuzzy(item, query, allowNonContiguousMatches, accessor, cache); 27 | return { item, score }; 28 | }); 29 | 30 | const allResultsWithScore = allResults.filter((result) => result.score.score > 0); 31 | 32 | return allResultsWithScore; 33 | } 34 | -------------------------------------------------------------------------------- /src/strings.ts: -------------------------------------------------------------------------------- 1 | export function startsWithIgnoreCase(str: string, candidate: string): boolean { 2 | const candidateLength = candidate.length; 3 | if (candidate.length > str.length) { 4 | return false; 5 | } 6 | 7 | return compareSubstringIgnoreCase(str, candidate, 0, candidateLength) === 0; 8 | } 9 | 10 | export function compareSubstringIgnoreCase( 11 | a: string, 12 | b: string, 13 | aStart = 0, 14 | aEnd: number = a.length, 15 | bStart = 0, 16 | bEnd: number = b.length, 17 | ): number { 18 | for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { 19 | let codeA = a.charCodeAt(aStart); 20 | let codeB = b.charCodeAt(bStart); 21 | 22 | if (codeA === codeB) { 23 | // equal 24 | continue; 25 | } 26 | 27 | if (codeA >= 128 || codeB >= 128) { 28 | // not ASCII letters -> fallback to lower-casing strings 29 | return compareSubstring(a.toLowerCase(), b.toLowerCase(), aStart, aEnd, bStart, bEnd); 30 | } 31 | 32 | // mapper lower-case ascii letter onto upper-case varinats 33 | // [97-122] (lower ascii) --> [65-90] (upper ascii) 34 | if (isLowerAsciiLetter(codeA)) { 35 | codeA -= 32; 36 | } 37 | if (isLowerAsciiLetter(codeB)) { 38 | codeB -= 32; 39 | } 40 | 41 | // compare both code points 42 | const diff = codeA - codeB; 43 | if (diff === 0) { 44 | continue; 45 | } 46 | 47 | return diff; 48 | } 49 | 50 | const aLen = aEnd - aStart; 51 | const bLen = bEnd - bStart; 52 | 53 | if (aLen < bLen) { 54 | return -1; 55 | } else if (aLen > bLen) { 56 | return 1; 57 | } 58 | 59 | return 0; 60 | } 61 | 62 | export function isLowerAsciiLetter(code: number): boolean { 63 | return code >= CharCode.a && code <= CharCode.z; 64 | } 65 | 66 | export function isUpperAsciiLetter(code: number): boolean { 67 | return code >= CharCode.A && code <= CharCode.Z; 68 | } 69 | 70 | export function equalsIgnoreCase(a: string, b: string): boolean { 71 | return a.length === b.length && compareSubstringIgnoreCase(a, b) === 0; 72 | } 73 | 74 | export function compareSubstring( 75 | a: string, 76 | b: string, 77 | aStart = 0, 78 | aEnd: number = a.length, 79 | bStart = 0, 80 | bEnd: number = b.length, 81 | ): number { 82 | for (; aStart < aEnd && bStart < bEnd; aStart++, bStart++) { 83 | const codeA = a.charCodeAt(aStart); 84 | const codeB = b.charCodeAt(bStart); 85 | if (codeA < codeB) { 86 | return -1; 87 | } else if (codeA > codeB) { 88 | return 1; 89 | } 90 | } 91 | const aLen = aEnd - aStart; 92 | const bLen = bEnd - bStart; 93 | if (aLen < bLen) { 94 | return -1; 95 | } else if (aLen > bLen) { 96 | return 1; 97 | } 98 | return 0; 99 | } 100 | 101 | export function isHighSurrogate(charCode: number): boolean { 102 | return 0xd800 <= charCode && charCode <= 0xdbff; 103 | } 104 | 105 | /** 106 | * See http://en.wikipedia.org/wiki/Surrogate_pair 107 | */ 108 | export function isLowSurrogate(charCode: number): boolean { 109 | return 0xdc00 <= charCode && charCode <= 0xdfff; 110 | } 111 | 112 | export function computeCodePoint(highSurrogate: number, lowSurrogate: number): number { 113 | return ((highSurrogate - 0xd800) << 10) + (lowSurrogate - 0xdc00) + 0x10000; 114 | } 115 | 116 | export const enum CharCode { 117 | Null = 0, 118 | /** 119 | * The `\b` character. 120 | */ 121 | Backspace = 8, 122 | /** 123 | * The `\t` character. 124 | */ 125 | Tab = 9, 126 | /** 127 | * The `\n` character. 128 | */ 129 | LineFeed = 10, 130 | /** 131 | * The `\r` character. 132 | */ 133 | CarriageReturn = 13, 134 | Space = 32, 135 | /** 136 | * The `!` character. 137 | */ 138 | ExclamationMark = 33, 139 | /** 140 | * The `"` character. 141 | */ 142 | DoubleQuote = 34, 143 | /** 144 | * The `#` character. 145 | */ 146 | Hash = 35, 147 | /** 148 | * The `$` character. 149 | */ 150 | DollarSign = 36, 151 | /** 152 | * The `%` character. 153 | */ 154 | PercentSign = 37, 155 | /** 156 | * The `&` character. 157 | */ 158 | Ampersand = 38, 159 | /** 160 | * The `'` character. 161 | */ 162 | SingleQuote = 39, 163 | /** 164 | * The `(` character. 165 | */ 166 | OpenParen = 40, 167 | /** 168 | * The `)` character. 169 | */ 170 | CloseParen = 41, 171 | /** 172 | * The `*` character. 173 | */ 174 | Asterisk = 42, 175 | /** 176 | * The `+` character. 177 | */ 178 | Plus = 43, 179 | /** 180 | * The `,` character. 181 | */ 182 | Comma = 44, 183 | /** 184 | * The `-` character. 185 | */ 186 | Dash = 45, 187 | /** 188 | * The `.` character. 189 | */ 190 | Period = 46, 191 | /** 192 | * The `/` character. 193 | */ 194 | Slash = 47, 195 | 196 | Digit0 = 48, 197 | Digit1 = 49, 198 | Digit2 = 50, 199 | Digit3 = 51, 200 | Digit4 = 52, 201 | Digit5 = 53, 202 | Digit6 = 54, 203 | Digit7 = 55, 204 | Digit8 = 56, 205 | Digit9 = 57, 206 | 207 | /** 208 | * The `:` character. 209 | */ 210 | Colon = 58, 211 | /** 212 | * The `;` character. 213 | */ 214 | Semicolon = 59, 215 | /** 216 | * The `<` character. 217 | */ 218 | LessThan = 60, 219 | /** 220 | * The `=` character. 221 | */ 222 | Equals = 61, 223 | /** 224 | * The `>` character. 225 | */ 226 | GreaterThan = 62, 227 | /** 228 | * The `?` character. 229 | */ 230 | QuestionMark = 63, 231 | /** 232 | * The `@` character. 233 | */ 234 | AtSign = 64, 235 | 236 | A = 65, 237 | B = 66, 238 | C = 67, 239 | D = 68, 240 | E = 69, 241 | F = 70, 242 | G = 71, 243 | H = 72, 244 | I = 73, 245 | J = 74, 246 | K = 75, 247 | L = 76, 248 | M = 77, 249 | N = 78, 250 | O = 79, 251 | P = 80, 252 | Q = 81, 253 | R = 82, 254 | S = 83, 255 | T = 84, 256 | U = 85, 257 | V = 86, 258 | W = 87, 259 | X = 88, 260 | Y = 89, 261 | Z = 90, 262 | 263 | /** 264 | * The `[` character. 265 | */ 266 | OpenSquareBracket = 91, 267 | /** 268 | * The `\` character. 269 | */ 270 | Backslash = 92, 271 | /** 272 | * The `]` character. 273 | */ 274 | CloseSquareBracket = 93, 275 | /** 276 | * The `^` character. 277 | */ 278 | Caret = 94, 279 | /** 280 | * The `_` character. 281 | */ 282 | Underline = 95, 283 | /** 284 | * The ``(`)`` character. 285 | */ 286 | BackTick = 96, 287 | 288 | a = 97, 289 | b = 98, 290 | c = 99, 291 | d = 100, 292 | e = 101, 293 | f = 102, 294 | g = 103, 295 | h = 104, 296 | i = 105, 297 | j = 106, 298 | k = 107, 299 | l = 108, 300 | m = 109, 301 | n = 110, 302 | o = 111, 303 | p = 112, 304 | q = 113, 305 | r = 114, 306 | s = 115, 307 | t = 116, 308 | u = 117, 309 | v = 118, 310 | w = 119, 311 | x = 120, 312 | y = 121, 313 | z = 122, 314 | 315 | /** 316 | * The `{` character. 317 | */ 318 | OpenCurlyBrace = 123, 319 | /** 320 | * The `|` character. 321 | */ 322 | Pipe = 124, 323 | /** 324 | * The `}` character. 325 | */ 326 | CloseCurlyBrace = 125, 327 | /** 328 | * The `~` character. 329 | */ 330 | Tilde = 126, 331 | 332 | U_Combining_Grave_Accent = 0x0300, // U+0300 Combining Grave Accent 333 | U_Combining_Acute_Accent = 0x0301, // U+0301 Combining Acute Accent 334 | U_Combining_Circumflex_Accent = 0x0302, // U+0302 Combining Circumflex Accent 335 | U_Combining_Tilde = 0x0303, // U+0303 Combining Tilde 336 | U_Combining_Macron = 0x0304, // U+0304 Combining Macron 337 | U_Combining_Overline = 0x0305, // U+0305 Combining Overline 338 | U_Combining_Breve = 0x0306, // U+0306 Combining Breve 339 | U_Combining_Dot_Above = 0x0307, // U+0307 Combining Dot Above 340 | U_Combining_Diaeresis = 0x0308, // U+0308 Combining Diaeresis 341 | U_Combining_Hook_Above = 0x0309, // U+0309 Combining Hook Above 342 | U_Combining_Ring_Above = 0x030a, // U+030A Combining Ring Above 343 | U_Combining_Double_Acute_Accent = 0x030b, // U+030B Combining Double Acute Accent 344 | U_Combining_Caron = 0x030c, // U+030C Combining Caron 345 | U_Combining_Vertical_Line_Above = 0x030d, // U+030D Combining Vertical Line Above 346 | U_Combining_Double_Vertical_Line_Above = 0x030e, // U+030E Combining Double Vertical Line Above 347 | U_Combining_Double_Grave_Accent = 0x030f, // U+030F Combining Double Grave Accent 348 | U_Combining_Candrabindu = 0x0310, // U+0310 Combining Candrabindu 349 | U_Combining_Inverted_Breve = 0x0311, // U+0311 Combining Inverted Breve 350 | U_Combining_Turned_Comma_Above = 0x0312, // U+0312 Combining Turned Comma Above 351 | U_Combining_Comma_Above = 0x0313, // U+0313 Combining Comma Above 352 | U_Combining_Reversed_Comma_Above = 0x0314, // U+0314 Combining Reversed Comma Above 353 | U_Combining_Comma_Above_Right = 0x0315, // U+0315 Combining Comma Above Right 354 | U_Combining_Grave_Accent_Below = 0x0316, // U+0316 Combining Grave Accent Below 355 | U_Combining_Acute_Accent_Below = 0x0317, // U+0317 Combining Acute Accent Below 356 | U_Combining_Left_Tack_Below = 0x0318, // U+0318 Combining Left Tack Below 357 | U_Combining_Right_Tack_Below = 0x0319, // U+0319 Combining Right Tack Below 358 | U_Combining_Left_Angle_Above = 0x031a, // U+031A Combining Left Angle Above 359 | U_Combining_Horn = 0x031b, // U+031B Combining Horn 360 | U_Combining_Left_Half_Ring_Below = 0x031c, // U+031C Combining Left Half Ring Below 361 | U_Combining_Up_Tack_Below = 0x031d, // U+031D Combining Up Tack Below 362 | U_Combining_Down_Tack_Below = 0x031e, // U+031E Combining Down Tack Below 363 | U_Combining_Plus_Sign_Below = 0x031f, // U+031F Combining Plus Sign Below 364 | U_Combining_Minus_Sign_Below = 0x0320, // U+0320 Combining Minus Sign Below 365 | U_Combining_Palatalized_Hook_Below = 0x0321, // U+0321 Combining Palatalized Hook Below 366 | U_Combining_Retroflex_Hook_Below = 0x0322, // U+0322 Combining Retroflex Hook Below 367 | U_Combining_Dot_Below = 0x0323, // U+0323 Combining Dot Below 368 | U_Combining_Diaeresis_Below = 0x0324, // U+0324 Combining Diaeresis Below 369 | U_Combining_Ring_Below = 0x0325, // U+0325 Combining Ring Below 370 | U_Combining_Comma_Below = 0x0326, // U+0326 Combining Comma Below 371 | U_Combining_Cedilla = 0x0327, // U+0327 Combining Cedilla 372 | U_Combining_Ogonek = 0x0328, // U+0328 Combining Ogonek 373 | U_Combining_Vertical_Line_Below = 0x0329, // U+0329 Combining Vertical Line Below 374 | U_Combining_Bridge_Below = 0x032a, // U+032A Combining Bridge Below 375 | U_Combining_Inverted_Double_Arch_Below = 0x032b, // U+032B Combining Inverted Double Arch Below 376 | U_Combining_Caron_Below = 0x032c, // U+032C Combining Caron Below 377 | U_Combining_Circumflex_Accent_Below = 0x032d, // U+032D Combining Circumflex Accent Below 378 | U_Combining_Breve_Below = 0x032e, // U+032E Combining Breve Below 379 | U_Combining_Inverted_Breve_Below = 0x032f, // U+032F Combining Inverted Breve Below 380 | U_Combining_Tilde_Below = 0x0330, // U+0330 Combining Tilde Below 381 | U_Combining_Macron_Below = 0x0331, // U+0331 Combining Macron Below 382 | U_Combining_Low_Line = 0x0332, // U+0332 Combining Low Line 383 | U_Combining_Double_Low_Line = 0x0333, // U+0333 Combining Double Low Line 384 | U_Combining_Tilde_Overlay = 0x0334, // U+0334 Combining Tilde Overlay 385 | U_Combining_Short_Stroke_Overlay = 0x0335, // U+0335 Combining Short Stroke Overlay 386 | U_Combining_Long_Stroke_Overlay = 0x0336, // U+0336 Combining Long Stroke Overlay 387 | U_Combining_Short_Solidus_Overlay = 0x0337, // U+0337 Combining Short Solidus Overlay 388 | U_Combining_Long_Solidus_Overlay = 0x0338, // U+0338 Combining Long Solidus Overlay 389 | U_Combining_Right_Half_Ring_Below = 0x0339, // U+0339 Combining Right Half Ring Below 390 | U_Combining_Inverted_Bridge_Below = 0x033a, // U+033A Combining Inverted Bridge Below 391 | U_Combining_Square_Below = 0x033b, // U+033B Combining Square Below 392 | U_Combining_Seagull_Below = 0x033c, // U+033C Combining Seagull Below 393 | U_Combining_X_Above = 0x033d, // U+033D Combining X Above 394 | U_Combining_Vertical_Tilde = 0x033e, // U+033E Combining Vertical Tilde 395 | U_Combining_Double_Overline = 0x033f, // U+033F Combining Double Overline 396 | U_Combining_Grave_Tone_Mark = 0x0340, // U+0340 Combining Grave Tone Mark 397 | U_Combining_Acute_Tone_Mark = 0x0341, // U+0341 Combining Acute Tone Mark 398 | U_Combining_Greek_Perispomeni = 0x0342, // U+0342 Combining Greek Perispomeni 399 | U_Combining_Greek_Koronis = 0x0343, // U+0343 Combining Greek Koronis 400 | U_Combining_Greek_Dialytika_Tonos = 0x0344, // U+0344 Combining Greek Dialytika Tonos 401 | U_Combining_Greek_Ypogegrammeni = 0x0345, // U+0345 Combining Greek Ypogegrammeni 402 | U_Combining_Bridge_Above = 0x0346, // U+0346 Combining Bridge Above 403 | U_Combining_Equals_Sign_Below = 0x0347, // U+0347 Combining Equals Sign Below 404 | U_Combining_Double_Vertical_Line_Below = 0x0348, // U+0348 Combining Double Vertical Line Below 405 | U_Combining_Left_Angle_Below = 0x0349, // U+0349 Combining Left Angle Below 406 | U_Combining_Not_Tilde_Above = 0x034a, // U+034A Combining Not Tilde Above 407 | U_Combining_Homothetic_Above = 0x034b, // U+034B Combining Homothetic Above 408 | U_Combining_Almost_Equal_To_Above = 0x034c, // U+034C Combining Almost Equal To Above 409 | U_Combining_Left_Right_Arrow_Below = 0x034d, // U+034D Combining Left Right Arrow Below 410 | U_Combining_Upwards_Arrow_Below = 0x034e, // U+034E Combining Upwards Arrow Below 411 | U_Combining_Grapheme_Joiner = 0x034f, // U+034F Combining Grapheme Joiner 412 | U_Combining_Right_Arrowhead_Above = 0x0350, // U+0350 Combining Right Arrowhead Above 413 | U_Combining_Left_Half_Ring_Above = 0x0351, // U+0351 Combining Left Half Ring Above 414 | U_Combining_Fermata = 0x0352, // U+0352 Combining Fermata 415 | U_Combining_X_Below = 0x0353, // U+0353 Combining X Below 416 | U_Combining_Left_Arrowhead_Below = 0x0354, // U+0354 Combining Left Arrowhead Below 417 | U_Combining_Right_Arrowhead_Below = 0x0355, // U+0355 Combining Right Arrowhead Below 418 | U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below = 0x0356, // U+0356 Combining Right Arrowhead And Up Arrowhead Below 419 | U_Combining_Right_Half_Ring_Above = 0x0357, // U+0357 Combining Right Half Ring Above 420 | U_Combining_Dot_Above_Right = 0x0358, // U+0358 Combining Dot Above Right 421 | U_Combining_Asterisk_Below = 0x0359, // U+0359 Combining Asterisk Below 422 | U_Combining_Double_Ring_Below = 0x035a, // U+035A Combining Double Ring Below 423 | U_Combining_Zigzag_Above = 0x035b, // U+035B Combining Zigzag Above 424 | U_Combining_Double_Breve_Below = 0x035c, // U+035C Combining Double Breve Below 425 | U_Combining_Double_Breve = 0x035d, // U+035D Combining Double Breve 426 | U_Combining_Double_Macron = 0x035e, // U+035E Combining Double Macron 427 | U_Combining_Double_Macron_Below = 0x035f, // U+035F Combining Double Macron Below 428 | U_Combining_Double_Tilde = 0x0360, // U+0360 Combining Double Tilde 429 | U_Combining_Double_Inverted_Breve = 0x0361, // U+0361 Combining Double Inverted Breve 430 | U_Combining_Double_Rightwards_Arrow_Below = 0x0362, // U+0362 Combining Double Rightwards Arrow Below 431 | U_Combining_Latin_Small_Letter_A = 0x0363, // U+0363 Combining Latin Small Letter A 432 | U_Combining_Latin_Small_Letter_E = 0x0364, // U+0364 Combining Latin Small Letter E 433 | U_Combining_Latin_Small_Letter_I = 0x0365, // U+0365 Combining Latin Small Letter I 434 | U_Combining_Latin_Small_Letter_O = 0x0366, // U+0366 Combining Latin Small Letter O 435 | U_Combining_Latin_Small_Letter_U = 0x0367, // U+0367 Combining Latin Small Letter U 436 | U_Combining_Latin_Small_Letter_C = 0x0368, // U+0368 Combining Latin Small Letter C 437 | U_Combining_Latin_Small_Letter_D = 0x0369, // U+0369 Combining Latin Small Letter D 438 | U_Combining_Latin_Small_Letter_H = 0x036a, // U+036A Combining Latin Small Letter H 439 | U_Combining_Latin_Small_Letter_M = 0x036b, // U+036B Combining Latin Small Letter M 440 | U_Combining_Latin_Small_Letter_R = 0x036c, // U+036C Combining Latin Small Letter R 441 | U_Combining_Latin_Small_Letter_T = 0x036d, // U+036D Combining Latin Small Letter T 442 | U_Combining_Latin_Small_Letter_V = 0x036e, // U+036E Combining Latin Small Letter V 443 | U_Combining_Latin_Small_Letter_X = 0x036f, // U+036F Combining Latin Small Letter X 444 | 445 | /** 446 | * Unicode Character 'LINE SEPARATOR' (U+2028) 447 | * http://www.fileformat.info/info/unicode/char/2028/index.htm 448 | */ 449 | LINE_SEPARATOR = 0x2028, 450 | /** 451 | * Unicode Character 'PARAGRAPH SEPARATOR' (U+2029) 452 | * http://www.fileformat.info/info/unicode/char/2029/index.htm 453 | */ 454 | PARAGRAPH_SEPARATOR = 0x2029, 455 | /** 456 | * Unicode Character 'NEXT LINE' (U+0085) 457 | * http://www.fileformat.info/info/unicode/char/0085/index.htm 458 | */ 459 | NEXT_LINE = 0x0085, 460 | 461 | // http://www.fileformat.info/info/unicode/category/Sk/list.htm 462 | U_CIRCUMFLEX = 0x005e, // U+005E CIRCUMFLEX 463 | U_GRAVE_ACCENT = 0x0060, // U+0060 GRAVE ACCENT 464 | U_DIAERESIS = 0x00a8, // U+00A8 DIAERESIS 465 | U_MACRON = 0x00af, // U+00AF MACRON 466 | U_ACUTE_ACCENT = 0x00b4, // U+00B4 ACUTE ACCENT 467 | U_CEDILLA = 0x00b8, // U+00B8 CEDILLA 468 | U_MODIFIER_LETTER_LEFT_ARROWHEAD = 0x02c2, // U+02C2 MODIFIER LETTER LEFT ARROWHEAD 469 | U_MODIFIER_LETTER_RIGHT_ARROWHEAD = 0x02c3, // U+02C3 MODIFIER LETTER RIGHT ARROWHEAD 470 | U_MODIFIER_LETTER_UP_ARROWHEAD = 0x02c4, // U+02C4 MODIFIER LETTER UP ARROWHEAD 471 | U_MODIFIER_LETTER_DOWN_ARROWHEAD = 0x02c5, // U+02C5 MODIFIER LETTER DOWN ARROWHEAD 472 | U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING = 0x02d2, // U+02D2 MODIFIER LETTER CENTRED RIGHT HALF RING 473 | U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING = 0x02d3, // U+02D3 MODIFIER LETTER CENTRED LEFT HALF RING 474 | U_MODIFIER_LETTER_UP_TACK = 0x02d4, // U+02D4 MODIFIER LETTER UP TACK 475 | U_MODIFIER_LETTER_DOWN_TACK = 0x02d5, // U+02D5 MODIFIER LETTER DOWN TACK 476 | U_MODIFIER_LETTER_PLUS_SIGN = 0x02d6, // U+02D6 MODIFIER LETTER PLUS SIGN 477 | U_MODIFIER_LETTER_MINUS_SIGN = 0x02d7, // U+02D7 MODIFIER LETTER MINUS SIGN 478 | U_BREVE = 0x02d8, // U+02D8 BREVE 479 | U_DOT_ABOVE = 0x02d9, // U+02D9 DOT ABOVE 480 | U_RING_ABOVE = 0x02da, // U+02DA RING ABOVE 481 | U_OGONEK = 0x02db, // U+02DB OGONEK 482 | U_SMALL_TILDE = 0x02dc, // U+02DC SMALL TILDE 483 | U_DOUBLE_ACUTE_ACCENT = 0x02dd, // U+02DD DOUBLE ACUTE ACCENT 484 | U_MODIFIER_LETTER_RHOTIC_HOOK = 0x02de, // U+02DE MODIFIER LETTER RHOTIC HOOK 485 | U_MODIFIER_LETTER_CROSS_ACCENT = 0x02df, // U+02DF MODIFIER LETTER CROSS ACCENT 486 | U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR = 0x02e5, // U+02E5 MODIFIER LETTER EXTRA-HIGH TONE BAR 487 | U_MODIFIER_LETTER_HIGH_TONE_BAR = 0x02e6, // U+02E6 MODIFIER LETTER HIGH TONE BAR 488 | U_MODIFIER_LETTER_MID_TONE_BAR = 0x02e7, // U+02E7 MODIFIER LETTER MID TONE BAR 489 | U_MODIFIER_LETTER_LOW_TONE_BAR = 0x02e8, // U+02E8 MODIFIER LETTER LOW TONE BAR 490 | U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR = 0x02e9, // U+02E9 MODIFIER LETTER EXTRA-LOW TONE BAR 491 | U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK = 0x02ea, // U+02EA MODIFIER LETTER YIN DEPARTING TONE MARK 492 | U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK = 0x02eb, // U+02EB MODIFIER LETTER YANG DEPARTING TONE MARK 493 | U_MODIFIER_LETTER_UNASPIRATED = 0x02ed, // U+02ED MODIFIER LETTER UNASPIRATED 494 | U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD = 0x02ef, // U+02EF MODIFIER LETTER LOW DOWN ARROWHEAD 495 | U_MODIFIER_LETTER_LOW_UP_ARROWHEAD = 0x02f0, // U+02F0 MODIFIER LETTER LOW UP ARROWHEAD 496 | U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD = 0x02f1, // U+02F1 MODIFIER LETTER LOW LEFT ARROWHEAD 497 | U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD = 0x02f2, // U+02F2 MODIFIER LETTER LOW RIGHT ARROWHEAD 498 | U_MODIFIER_LETTER_LOW_RING = 0x02f3, // U+02F3 MODIFIER LETTER LOW RING 499 | U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT = 0x02f4, // U+02F4 MODIFIER LETTER MIDDLE GRAVE ACCENT 500 | U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT = 0x02f5, // U+02F5 MODIFIER LETTER MIDDLE DOUBLE GRAVE ACCENT 501 | U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT = 0x02f6, // U+02F6 MODIFIER LETTER MIDDLE DOUBLE ACUTE ACCENT 502 | U_MODIFIER_LETTER_LOW_TILDE = 0x02f7, // U+02F7 MODIFIER LETTER LOW TILDE 503 | U_MODIFIER_LETTER_RAISED_COLON = 0x02f8, // U+02F8 MODIFIER LETTER RAISED COLON 504 | U_MODIFIER_LETTER_BEGIN_HIGH_TONE = 0x02f9, // U+02F9 MODIFIER LETTER BEGIN HIGH TONE 505 | U_MODIFIER_LETTER_END_HIGH_TONE = 0x02fa, // U+02FA MODIFIER LETTER END HIGH TONE 506 | U_MODIFIER_LETTER_BEGIN_LOW_TONE = 0x02fb, // U+02FB MODIFIER LETTER BEGIN LOW TONE 507 | U_MODIFIER_LETTER_END_LOW_TONE = 0x02fc, // U+02FC MODIFIER LETTER END LOW TONE 508 | U_MODIFIER_LETTER_SHELF = 0x02fd, // U+02FD MODIFIER LETTER SHELF 509 | U_MODIFIER_LETTER_OPEN_SHELF = 0x02fe, // U+02FE MODIFIER LETTER OPEN SHELF 510 | U_MODIFIER_LETTER_LOW_LEFT_ARROW = 0x02ff, // U+02FF MODIFIER LETTER LOW LEFT ARROW 511 | U_GREEK_LOWER_NUMERAL_SIGN = 0x0375, // U+0375 GREEK LOWER NUMERAL SIGN 512 | U_GREEK_TONOS = 0x0384, // U+0384 GREEK TONOS 513 | U_GREEK_DIALYTIKA_TONOS = 0x0385, // U+0385 GREEK DIALYTIKA TONOS 514 | U_GREEK_KORONIS = 0x1fbd, // U+1FBD GREEK KORONIS 515 | U_GREEK_PSILI = 0x1fbf, // U+1FBF GREEK PSILI 516 | U_GREEK_PERISPOMENI = 0x1fc0, // U+1FC0 GREEK PERISPOMENI 517 | U_GREEK_DIALYTIKA_AND_PERISPOMENI = 0x1fc1, // U+1FC1 GREEK DIALYTIKA AND PERISPOMENI 518 | U_GREEK_PSILI_AND_VARIA = 0x1fcd, // U+1FCD GREEK PSILI AND VARIA 519 | U_GREEK_PSILI_AND_OXIA = 0x1fce, // U+1FCE GREEK PSILI AND OXIA 520 | U_GREEK_PSILI_AND_PERISPOMENI = 0x1fcf, // U+1FCF GREEK PSILI AND PERISPOMENI 521 | U_GREEK_DASIA_AND_VARIA = 0x1fdd, // U+1FDD GREEK DASIA AND VARIA 522 | U_GREEK_DASIA_AND_OXIA = 0x1fde, // U+1FDE GREEK DASIA AND OXIA 523 | U_GREEK_DASIA_AND_PERISPOMENI = 0x1fdf, // U+1FDF GREEK DASIA AND PERISPOMENI 524 | U_GREEK_DIALYTIKA_AND_VARIA = 0x1fed, // U+1FED GREEK DIALYTIKA AND VARIA 525 | U_GREEK_DIALYTIKA_AND_OXIA = 0x1fee, // U+1FEE GREEK DIALYTIKA AND OXIA 526 | U_GREEK_VARIA = 0x1fef, // U+1FEF GREEK VARIA 527 | U_GREEK_OXIA = 0x1ffd, // U+1FFD GREEK OXIA 528 | U_GREEK_DASIA = 0x1ffe, // U+1FFE GREEK DASIA 529 | 530 | U_IDEOGRAPHIC_FULL_STOP = 0x3002, // U+3002 IDEOGRAPHIC FULL STOP 531 | U_LEFT_CORNER_BRACKET = 0x300c, // U+300C LEFT CORNER BRACKET 532 | U_RIGHT_CORNER_BRACKET = 0x300d, // U+300D RIGHT CORNER BRACKET 533 | U_LEFT_BLACK_LENTICULAR_BRACKET = 0x3010, // U+3010 LEFT BLACK LENTICULAR BRACKET 534 | U_RIGHT_BLACK_LENTICULAR_BRACKET = 0x3011, // U+3011 RIGHT BLACK LENTICULAR BRACKET 535 | 536 | U_OVERLINE = 0x203e, // Unicode Character 'OVERLINE' 537 | 538 | /** 539 | * UTF-8 BOM 540 | * Unicode Character 'ZERO WIDTH NO-BREAK SPACE' (U+FEFF) 541 | * http://www.fileformat.info/info/unicode/char/feff/index.htm 542 | */ 543 | UTF8_BOM = 65279, 544 | 545 | U_FULLWIDTH_SEMICOLON = 0xff1b, // U+FF1B FULLWIDTH SEMICOLON 546 | U_FULLWIDTH_COMMA = 0xff0c, // U+FF0C FULLWIDTH COMMA 547 | } 548 | -------------------------------------------------------------------------------- /tests/jsonHeroSearch.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import path from "node:path"; 3 | import { JSONHeroSearch } from "../src"; 4 | 5 | test("JSONHeroSearch can search with just json and a query", () => { 6 | const json = { 7 | foo: "bar", 8 | baz: "qux", 9 | user: { 10 | email: "eric@stackhero.run", 11 | name: "Eric", 12 | age: 27, 13 | photos: [ 14 | { 15 | url: "https://avatars0.githubusercontent.com/u/1234?v=4", 16 | width: 100, 17 | height: 100, 18 | createdAt: "2020-01-01T00:00:00.000Z", 19 | }, 20 | ], 21 | }, 22 | github: { 23 | profile: { 24 | id: "ericallam", 25 | name: "Eric Allam", 26 | repo: { 27 | id: "ericallam/jsonhero", 28 | name: "jsonhero", 29 | description: "A JSON IDE for the web", 30 | }, 31 | }, 32 | }, 33 | }; 34 | 35 | const searcher = new JSONHeroSearch(json); 36 | 37 | expect(searcher.search("foo")).toMatchInlineSnapshot(` 38 | Array [ 39 | Object { 40 | "item": "$.foo", 41 | "score": Object { 42 | "description": "", 43 | "descriptionMatch": undefined, 44 | "formattedValue": "bar", 45 | "label": "foo", 46 | "labelMatch": Array [ 47 | Object { 48 | "end": 3, 49 | "start": 0, 50 | }, 51 | ], 52 | "rawValue": "bar", 53 | "score": 262144, 54 | }, 55 | }, 56 | Object { 57 | "item": "$.github.profile.repo.description", 58 | "score": Object { 59 | "description": "github.profile.repo", 60 | "descriptionMatch": Array [ 61 | Object { 62 | "end": 11, 63 | "start": 10, 64 | }, 65 | Object { 66 | "end": 19, 67 | "start": 18, 68 | }, 69 | ], 70 | "formattedValue": "A JSON IDE for the web", 71 | "label": "description", 72 | "labelMatch": Array [ 73 | Object { 74 | "end": 10, 75 | "start": 9, 76 | }, 77 | ], 78 | "rawValue": "A JSON IDE for the web", 79 | "score": 6, 80 | }, 81 | }, 82 | ] 83 | `); 84 | 85 | expect(searcher.search("github")).toMatchInlineSnapshot(` 86 | Array [ 87 | Object { 88 | "item": "$.github", 89 | "score": Object { 90 | "description": "", 91 | "descriptionMatch": undefined, 92 | "formattedValue": undefined, 93 | "label": "github", 94 | "labelMatch": Array [ 95 | Object { 96 | "end": 6, 97 | "start": 0, 98 | }, 99 | ], 100 | "rawValue": undefined, 101 | "score": 262144, 102 | }, 103 | }, 104 | Object { 105 | "item": "$.github.profile", 106 | "score": Object { 107 | "description": "github", 108 | "descriptionMatch": Array [ 109 | Object { 110 | "end": 6, 111 | "start": 0, 112 | }, 113 | ], 114 | "formattedValue": undefined, 115 | "label": "profile", 116 | "labelMatch": Array [], 117 | "rawValue": undefined, 118 | "score": 95, 119 | }, 120 | }, 121 | Object { 122 | "item": "$.github.profile.id", 123 | "score": Object { 124 | "description": "github.profile", 125 | "descriptionMatch": Array [ 126 | Object { 127 | "end": 6, 128 | "start": 0, 129 | }, 130 | ], 131 | "formattedValue": "ericallam", 132 | "label": "id", 133 | "labelMatch": Array [], 134 | "rawValue": "ericallam", 135 | "score": 95, 136 | }, 137 | }, 138 | Object { 139 | "item": "$.github.profile.name", 140 | "score": Object { 141 | "description": "github.profile", 142 | "descriptionMatch": Array [ 143 | Object { 144 | "end": 6, 145 | "start": 0, 146 | }, 147 | ], 148 | "formattedValue": "Eric Allam", 149 | "label": "name", 150 | "labelMatch": Array [], 151 | "rawValue": "Eric Allam", 152 | "score": 95, 153 | }, 154 | }, 155 | Object { 156 | "item": "$.github.profile.repo", 157 | "score": Object { 158 | "description": "github.profile", 159 | "descriptionMatch": Array [ 160 | Object { 161 | "end": 6, 162 | "start": 0, 163 | }, 164 | ], 165 | "formattedValue": undefined, 166 | "label": "repo", 167 | "labelMatch": Array [], 168 | "rawValue": undefined, 169 | "score": 95, 170 | }, 171 | }, 172 | Object { 173 | "item": "$.github.profile.repo.id", 174 | "score": Object { 175 | "description": "github.profile.repo", 176 | "descriptionMatch": Array [ 177 | Object { 178 | "end": 6, 179 | "start": 0, 180 | }, 181 | ], 182 | "formattedValue": "ericallam/jsonhero", 183 | "label": "id", 184 | "labelMatch": Array [], 185 | "rawValue": "ericallam/jsonhero", 186 | "score": 95, 187 | }, 188 | }, 189 | Object { 190 | "item": "$.github.profile.repo.name", 191 | "score": Object { 192 | "description": "github.profile.repo", 193 | "descriptionMatch": Array [ 194 | Object { 195 | "end": 6, 196 | "start": 0, 197 | }, 198 | ], 199 | "formattedValue": "jsonhero", 200 | "label": "name", 201 | "labelMatch": Array [], 202 | "rawValue": "jsonhero", 203 | "score": 95, 204 | }, 205 | }, 206 | Object { 207 | "item": "$.github.profile.repo.description", 208 | "score": Object { 209 | "description": "github.profile.repo", 210 | "descriptionMatch": Array [ 211 | Object { 212 | "end": 6, 213 | "start": 0, 214 | }, 215 | ], 216 | "formattedValue": "A JSON IDE for the web", 217 | "label": "description", 218 | "labelMatch": Array [], 219 | "rawValue": "A JSON IDE for the web", 220 | "score": 95, 221 | }, 222 | }, 223 | Object { 224 | "item": "$.user.photos.0.url", 225 | "score": Object { 226 | "description": "user.photos.0", 227 | "formattedValue": "https://avatars0.githubusercontent.com/u/1234?v=4", 228 | "label": "url", 229 | "rawValue": "https://avatars0.githubusercontent.com/u/1234?v=4", 230 | "rawValueMatch": Array [ 231 | Object { 232 | "end": 23, 233 | "start": 17, 234 | }, 235 | ], 236 | "score": 87, 237 | }, 238 | }, 239 | ] 240 | `); 241 | }); 242 | 243 | test("custom formatter option", () => { 244 | const json = { 245 | dateString: "2020-01-01T00:00:00.000Z", 246 | }; 247 | 248 | function dateStringFormatter(value: unknown): string | undefined { 249 | if (typeof value === "string") { 250 | try { 251 | return new Date(value).toString(); 252 | } catch { 253 | return value; 254 | } 255 | } 256 | 257 | return; 258 | } 259 | 260 | const searcher = new JSONHeroSearch(json, { formatter: dateStringFormatter }); 261 | 262 | const results = searcher.search("Jan"); 263 | 264 | expect(results.length).toBe(1); 265 | expect(results).toMatchInlineSnapshot(` 266 | Array [ 267 | Object { 268 | "item": "$.dateString", 269 | "score": Object { 270 | "description": "", 271 | "formattedValue": "Wed Jan 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time)", 272 | "formattedValueMatch": Array [ 273 | Object { 274 | "end": 7, 275 | "start": 4, 276 | }, 277 | ], 278 | "label": "dateString", 279 | "rawValue": "2020-01-01T00:00:00.000Z", 280 | "score": 23, 281 | }, 282 | }, 283 | ] 284 | `); 285 | }); 286 | 287 | test("JSONHeroSearch should be fast enough, and results cached", () => { 288 | // Read in fixtures/airtable.json 289 | const json = JSON.parse(readFileSync(path.join(__dirname, "fixtures/airtable.json"), "utf8")); 290 | 291 | const searcher = new JSONHeroSearch(json); 292 | searcher.prepareIndex(); 293 | 294 | let start = performance.now(); 295 | 296 | searcher.search("url"); 297 | 298 | let end = performance.now(); 299 | 300 | expect(end - start).toBeLessThan(500); 301 | 302 | start = performance.now(); 303 | 304 | searcher.search("url"); 305 | 306 | end = performance.now(); 307 | 308 | expect(end - start).toBeLessThan(10); 309 | }); 310 | -------------------------------------------------------------------------------- /tests/scoring.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | prepareQuery, 3 | FuzzyScore, 4 | scoreFuzzy, 5 | ItemScore, 6 | scoreItemFuzzy, 7 | IItemAccessor, 8 | compareItemsByFuzzyScore, 9 | } from "../src/fuzzyScoring"; 10 | 11 | import { JsonHeroPathAccessor } from "./utils/jsonHeroAccessor"; 12 | 13 | function scoreItem( 14 | item: T, 15 | query: string, 16 | allowNonContiguousMatches: boolean, 17 | accessor: IItemAccessor, 18 | cache = new Map(), 19 | ): ItemScore { 20 | return scoreItemFuzzy(item, prepareQuery(query), allowNonContiguousMatches, accessor, cache); 21 | } 22 | 23 | function compareItemsByScore( 24 | itemA: T, 25 | itemB: T, 26 | query: string, 27 | allowNonContiguousMatches: boolean, 28 | accessor: IItemAccessor, 29 | ): number { 30 | return compareItemsByFuzzyScore( 31 | itemA, 32 | itemB, 33 | prepareQuery(query), 34 | allowNonContiguousMatches, 35 | accessor, 36 | new Map(), 37 | ); 38 | } 39 | 40 | function _doScore(target: string, query: string, allowNonContiguousMatches?: boolean): FuzzyScore { 41 | const preparedQuery = prepareQuery(query); 42 | 43 | return scoreFuzzy( 44 | target, 45 | preparedQuery.normalized, 46 | preparedQuery.normalizedLowercase, 47 | allowNonContiguousMatches ?? !preparedQuery.expectContiguousMatch, 48 | ); 49 | } 50 | 51 | const jsonHeroPathAccessor = new JsonHeroPathAccessor({ 52 | xyz: { 53 | some: { 54 | path: { 55 | someKey123: "2022-01-01T00:00:00.000Z", 56 | aKey456: "zyx.others.spath.some.xsp.file123", 57 | }, 58 | }, 59 | }, 60 | }); 61 | 62 | test("JsonHeroPathAccessor", () => { 63 | const jsonPath = "$.xyz.some.path.someKey123"; 64 | 65 | expect(jsonHeroPathAccessor.getItemLabel(jsonPath)).toBe("someKey123"); 66 | expect(jsonHeroPathAccessor.getItemDescription(jsonPath)).toBe("xyz.some.path"); 67 | expect(jsonHeroPathAccessor.getItemPath(jsonPath)).toBe("xyz.some.path.someKey123"); 68 | expect(jsonHeroPathAccessor.getRawValue(jsonPath)).toBe("2022-01-01T00:00:00.000Z"); 69 | expect(jsonHeroPathAccessor.getFormattedValue(jsonPath)).toBe( 70 | "Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)", 71 | ); 72 | }); 73 | 74 | test("scoreItem", () => { 75 | const jsonPath = "$.xyz.some.path.someKey123"; 76 | 77 | const pathIdentity = scoreItem( 78 | jsonPath, 79 | jsonHeroPathAccessor.getItemPath(jsonPath), 80 | true, 81 | jsonHeroPathAccessor, 82 | ); 83 | 84 | expect(pathIdentity.score).toBeGreaterThan(0); 85 | expect(pathIdentity.labelMatch?.length).toBe(1); 86 | expect(pathIdentity.labelMatch?.[0].start).toBe(0); 87 | expect(pathIdentity.labelMatch?.[0].end).toBe(jsonHeroPathAccessor.getItemLabel(jsonPath).length); 88 | expect(pathIdentity.descriptionMatch?.length).toBe(1); 89 | expect(pathIdentity.descriptionMatch?.[0].start).toBe(0); 90 | expect(pathIdentity.descriptionMatch?.[0].end).toBe( 91 | jsonHeroPathAccessor.getItemDescription(jsonPath).length, 92 | ); 93 | expect(pathIdentity.rawValueMatch).toBeUndefined(); 94 | expect(pathIdentity.formattedValueMatch).toBeUndefined(); 95 | expect(pathIdentity.label).toBe("someKey123"); 96 | expect(pathIdentity.description).toBe("xyz.some.path"); 97 | expect(pathIdentity.rawValue).toBe("2022-01-01T00:00:00.000Z"); 98 | expect(pathIdentity.formattedValue).toBe( 99 | "Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)", 100 | ); 101 | 102 | const labelPrefix = scoreItem(jsonPath, "som", true, jsonHeroPathAccessor); 103 | expect(labelPrefix.score).toBeGreaterThan(0); 104 | expect(labelPrefix.descriptionMatch).toBeUndefined(); 105 | expect(labelPrefix.rawValueMatch).toBeUndefined(); 106 | expect(labelPrefix.formattedValueMatch).toBeUndefined(); 107 | expect(labelPrefix.labelMatch?.length).toBe(1); 108 | expect(labelPrefix.labelMatch?.[0].start).toBe(0); 109 | expect(labelPrefix.labelMatch?.[0].end).toBe("som".length); 110 | expect(labelPrefix.label).toBe("someKey123"); 111 | expect(labelPrefix.description).toBe("xyz.some.path"); 112 | expect(labelPrefix.rawValue).toBe("2022-01-01T00:00:00.000Z"); 113 | expect(labelPrefix.formattedValue).toBe( 114 | "Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)", 115 | ); 116 | 117 | const labelCamelcase = scoreItem(jsonPath, "sK", true, jsonHeroPathAccessor); 118 | expect(labelCamelcase.score).toBeGreaterThan(0); 119 | expect(labelCamelcase.descriptionMatch).toBeUndefined(); 120 | expect(labelCamelcase.rawValueMatch).toBeUndefined(); 121 | expect(labelCamelcase.formattedValueMatch).toBeUndefined(); 122 | expect(labelCamelcase.labelMatch?.length).toBe(2); 123 | expect(labelCamelcase.labelMatch?.[0].start).toBe(0); 124 | expect(labelCamelcase.labelMatch?.[0].end).toBe(1); 125 | expect(labelCamelcase.labelMatch?.[1].start).toBe(4); 126 | expect(labelCamelcase.labelMatch?.[1].end).toBe(5); 127 | expect(labelCamelcase.label).toBe("someKey123"); 128 | expect(labelCamelcase.description).toBe("xyz.some.path"); 129 | expect(labelCamelcase.rawValue).toBe("2022-01-01T00:00:00.000Z"); 130 | expect(labelCamelcase.formattedValue).toBe( 131 | "Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)", 132 | ); 133 | 134 | const labelMatch = scoreItem(jsonPath, "ok", true, jsonHeroPathAccessor); 135 | expect(labelMatch.score).toBeGreaterThan(0); 136 | expect(labelMatch.descriptionMatch).toBeUndefined(); 137 | expect(labelMatch.rawValueMatch).toBeUndefined(); 138 | expect(labelMatch.formattedValueMatch).toBeUndefined(); 139 | expect(labelMatch.labelMatch?.length).toBe(2); 140 | expect(labelMatch.labelMatch?.[0].start).toBe(1); 141 | expect(labelMatch.labelMatch?.[0].end).toBe(2); 142 | expect(labelMatch.labelMatch?.[1].start).toBe(4); 143 | expect(labelMatch.labelMatch?.[1].end).toBe(5); 144 | expect(labelMatch.label).toBe("someKey123"); 145 | expect(labelMatch.description).toBe("xyz.some.path"); 146 | expect(labelMatch.rawValue).toBe("2022-01-01T00:00:00.000Z"); 147 | expect(labelMatch.formattedValue).toBe("Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)"); 148 | 149 | const pathMatch = scoreItem(jsonPath, "xyz123", true, jsonHeroPathAccessor); 150 | expect(pathMatch.score).toBeGreaterThan(0); 151 | expect(pathMatch.descriptionMatch).toBeDefined(); 152 | expect(pathMatch.rawValueMatch).toBeUndefined(); 153 | expect(pathMatch.formattedValueMatch).toBeUndefined(); 154 | expect(pathMatch.labelMatch).toBeDefined(); 155 | expect(pathMatch.labelMatch?.length).toBe(1); 156 | expect(pathMatch.labelMatch?.[0].start).toBe(7); 157 | expect(pathMatch.labelMatch?.[0].end).toBe(10); 158 | expect(pathMatch.label).toBe("someKey123"); 159 | expect(pathMatch.description).toBe("xyz.some.path"); 160 | expect(pathMatch.rawValue).toBe("2022-01-01T00:00:00.000Z"); 161 | expect(pathMatch.formattedValue).toBe("Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)"); 162 | 163 | const rawValueMatch = scoreItem(jsonPath, "-", true, jsonHeroPathAccessor); 164 | expect(rawValueMatch.score).toBeGreaterThan(0); 165 | expect(rawValueMatch.labelMatch).toBeUndefined(); 166 | expect(rawValueMatch.descriptionMatch).toBeUndefined(); 167 | expect(rawValueMatch.formattedValueMatch).toBeUndefined(); 168 | expect(rawValueMatch.rawValueMatch).toBeDefined(); 169 | expect(rawValueMatch.rawValueMatch?.length).toBe(1); 170 | expect(rawValueMatch.rawValueMatch?.[0].start).toBe(7); 171 | expect(rawValueMatch.rawValueMatch?.[0].end).toBe(8); 172 | expect(rawValueMatch.label).toBe("someKey123"); 173 | expect(rawValueMatch.description).toBe("xyz.some.path"); 174 | expect(rawValueMatch.rawValue).toBe("2022-01-01T00:00:00.000Z"); 175 | expect(rawValueMatch.formattedValue).toBe( 176 | "Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)", 177 | ); 178 | 179 | const formattedValueMatch = scoreItem(jsonPath, "wich", true, jsonHeroPathAccessor); 180 | expect(formattedValueMatch.score).toBeGreaterThan(0); 181 | expect(formattedValueMatch.labelMatch).toBeUndefined(); 182 | expect(formattedValueMatch.descriptionMatch).toBeUndefined(); 183 | expect(formattedValueMatch.rawValueMatch).toBeUndefined(); 184 | expect(formattedValueMatch.formattedValueMatch).toBeDefined(); 185 | expect(formattedValueMatch.formattedValueMatch?.length).toBe(1); 186 | expect(formattedValueMatch.formattedValueMatch?.[0].start).toBe(40); 187 | expect(formattedValueMatch.formattedValueMatch?.[0].end).toBe(44); 188 | expect(formattedValueMatch.label).toBe("someKey123"); 189 | expect(formattedValueMatch.description).toBe("xyz.some.path"); 190 | expect(formattedValueMatch.rawValue).toBe("2022-01-01T00:00:00.000Z"); 191 | expect(formattedValueMatch.formattedValue).toBe( 192 | "Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)", 193 | ); 194 | 195 | const valueMatch = scoreItem(jsonPath, "2022", true, jsonHeroPathAccessor); 196 | expect(valueMatch.score).toBeGreaterThan(0); 197 | expect(valueMatch.labelMatch).toBeUndefined(); 198 | expect(valueMatch.descriptionMatch).toBeUndefined(); 199 | expect(valueMatch.rawValueMatch).toBeDefined(); 200 | expect(valueMatch.formattedValueMatch).toBeDefined(); 201 | expect(valueMatch.rawValueMatch?.length).toBe(1); 202 | expect(valueMatch.rawValueMatch?.[0].start).toBe(0); 203 | expect(valueMatch.rawValueMatch?.[0].end).toBe(4); 204 | expect(valueMatch.formattedValueMatch?.length).toBe(1); 205 | expect(valueMatch.formattedValueMatch?.[0].start).toBe(11); 206 | expect(valueMatch.formattedValueMatch?.[0].end).toBe(15); 207 | expect(valueMatch.label).toBe("someKey123"); 208 | expect(valueMatch.description).toBe("xyz.some.path"); 209 | expect(valueMatch.rawValue).toBe("2022-01-01T00:00:00.000Z"); 210 | expect(valueMatch.formattedValue).toBe("Sat Jan 01 2022 00:00:00 GMT+0000 (Greenwich Mean Time)"); 211 | 212 | // No Match 213 | const noMatch = scoreItem(jsonPath, "987", true, jsonHeroPathAccessor); 214 | expect(noMatch.score).toBe(0); 215 | expect(noMatch.labelMatch).toBeUndefined(); 216 | expect(noMatch.descriptionMatch).toBeUndefined(); 217 | expect(noMatch.rawValueMatch).toBeUndefined(); 218 | expect(noMatch.formattedValueMatch).toBeUndefined(); 219 | 220 | // No Exact Match 221 | const noExactMatch = scoreItem(jsonPath, '"sK"', true, jsonHeroPathAccessor); 222 | expect(noExactMatch.score).toBe(0); 223 | expect(noExactMatch.labelMatch).toBeUndefined(); 224 | expect(noExactMatch.descriptionMatch).toBeUndefined(); 225 | expect(noExactMatch.rawValueMatch).toBeUndefined(); 226 | expect(noExactMatch.formattedValueMatch).toBeUndefined(); 227 | 228 | expect(pathIdentity.score).toBeGreaterThan(labelPrefix.score); 229 | expect(labelPrefix.score).toBeGreaterThan(labelCamelcase.score); 230 | expect(labelCamelcase.score).toBeGreaterThan(labelMatch.score); 231 | expect(labelMatch.score).toBeGreaterThan(pathMatch.score); 232 | expect(pathMatch.score).toBeGreaterThan(rawValueMatch.score); 233 | expect(pathMatch.score).toBeGreaterThan(formattedValueMatch.score); 234 | }); 235 | 236 | test("scoreItem (multiple)", () => { 237 | const jsonPath = "$.xyz.some.path.someKey123"; 238 | 239 | const res1 = scoreItem(jsonPath, "xyz some", true, jsonHeroPathAccessor); 240 | expect(res1.score).toBeGreaterThan(0); 241 | expect(res1.labelMatch?.length).toBe(1); 242 | expect(res1.labelMatch?.[0].start).toBe(0); 243 | expect(res1.labelMatch?.[0].end).toBe(4); 244 | expect(res1.descriptionMatch).toBeDefined(); 245 | expect(res1.descriptionMatch?.length).toBe(1); 246 | expect(res1.descriptionMatch?.[0].start).toBe(0); 247 | expect(res1.descriptionMatch?.[0].end).toBe(3); 248 | expect(res1.rawValueMatch).toBeUndefined(); 249 | expect(res1.formattedValueMatch).toBeUndefined(); 250 | 251 | const res2 = scoreItem(jsonPath, "some xyz", true, jsonHeroPathAccessor); 252 | expect(res2.score).toStrictEqual(res1.score); 253 | expect(res2.score).toBeGreaterThan(0); 254 | expect(res2.labelMatch?.length).toBe(1); 255 | expect(res2.labelMatch?.[0].start).toBe(0); 256 | expect(res2.labelMatch?.[0].end).toBe(4); 257 | expect(res2.descriptionMatch).toBeDefined(); 258 | expect(res2.descriptionMatch?.length).toBe(1); 259 | expect(res2.descriptionMatch?.[0].start).toBe(0); 260 | expect(res2.descriptionMatch?.[0].end).toBe(3); 261 | expect(res2.rawValueMatch).toBeUndefined(); 262 | expect(res2.formattedValueMatch).toBeUndefined(); 263 | 264 | const res3 = scoreItem(jsonPath, "some xyz key key123", true, jsonHeroPathAccessor); 265 | expect(res3.score).toBeGreaterThan(res2.score); 266 | expect(res3.labelMatch?.length).toBe(1); 267 | expect(res3.labelMatch?.[0].start).toBe(0); 268 | expect(res3.labelMatch?.[0].end).toBe(10); 269 | expect(res3.descriptionMatch?.length).toBe(1); 270 | expect(res3.descriptionMatch?.[0].start).toBe(0); 271 | expect(res3.descriptionMatch?.[0].end).toBe(3); 272 | expect(res3.rawValueMatch).toBeUndefined(); 273 | expect(res3.formattedValueMatch).toBeUndefined(); 274 | 275 | const res4 = scoreItem(jsonPath, "path z x", true, jsonHeroPathAccessor); 276 | expect(res4.score).toBeGreaterThan(0); 277 | expect(res4.score).toBeLessThan(res2.score); 278 | expect(res4.labelMatch).toBeUndefined(); 279 | expect(res4.descriptionMatch?.length).toBe(3); 280 | expect(res4.rawValueMatch?.length).toBe(1); 281 | }); 282 | 283 | test("scoreItem - multiple with cache yields different results", () => { 284 | const jsonPath = "$.xyz.some.path.someKey123"; 285 | const cache = new Map(); 286 | const res1 = scoreItem(jsonPath, "xyz sm", true, jsonHeroPathAccessor, cache); 287 | expect(res1.score).toBeGreaterThan(0); 288 | 289 | // from the cache's perspective this should be a totally different query 290 | const res2 = scoreItem(jsonPath, 'xyz "sm"', true, jsonHeroPathAccessor, cache); 291 | expect(res2.score).toBe(0); 292 | 293 | expect(cache.size).toBe(2); 294 | }); 295 | 296 | test("scoreItem - invalid input", function () { 297 | let res = scoreItem(null, null!, true, jsonHeroPathAccessor); 298 | expect(res.score).toBe(0); 299 | 300 | res = scoreItem(null, "null", true, jsonHeroPathAccessor); 301 | expect(res.score).toBe(0); 302 | }); 303 | 304 | test("scoreItem - optimize for paths", function () { 305 | const jsonPath = "$.zyx.others.spath.some.xsp.file123"; 306 | 307 | // xsp is more relevant to the end of the path even though it matches 308 | // fuzzy also in the beginning. we verify the more relevant match at the 309 | // end gets returned. 310 | const pathRes = scoreItem(jsonPath, "xspfile123", true, jsonHeroPathAccessor); 311 | expect(pathRes.score).toBeGreaterThan(0); 312 | expect(pathRes.descriptionMatch).toBeDefined(); 313 | expect(pathRes.labelMatch).toBeDefined(); 314 | expect(pathRes.labelMatch?.length).toStrictEqual(1); 315 | expect(pathRes.labelMatch?.[0].start).toStrictEqual(0); 316 | expect(pathRes.labelMatch?.[0].end).toStrictEqual(7); 317 | expect(pathRes.descriptionMatch?.length).toStrictEqual(1); 318 | expect(pathRes.descriptionMatch?.[0].start).toStrictEqual(22); 319 | expect(pathRes.descriptionMatch?.[0].end).toStrictEqual(25); 320 | }); 321 | 322 | test("scoreItem - don't find formattedValueMatches when rawValue == formattedValue", function () { 323 | const jsonPath = "$.xyz.some.path.aKey456"; 324 | 325 | const pathRes = scoreItem(jsonPath, "xspfile123", true, jsonHeroPathAccessor); 326 | expect(pathRes.score).toBeGreaterThan(0); 327 | expect(pathRes.rawValueMatch).toBeDefined(); 328 | expect(pathRes.formattedValueMatch).toBeUndefined(); 329 | }); 330 | 331 | test("compareItemsByScore - identity", function () { 332 | const jsonPathA = "$.some.path.fileA"; 333 | const jsonPathB = "$.some.path.other.fileB"; 334 | const jsonPathC = "$.unrelated.some.path.other.fileC"; 335 | 336 | // Full resource A path 337 | let query = jsonHeroPathAccessor.getItemPath(jsonPathA); 338 | 339 | let res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 340 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 341 | ); 342 | 343 | expect(res[0]).toStrictEqual(jsonPathA); 344 | expect(res[1]).toStrictEqual(jsonPathB); 345 | expect(res[2]).toStrictEqual(jsonPathC); 346 | 347 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 348 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 349 | ); 350 | expect(res[0]).toStrictEqual(jsonPathA); 351 | expect(res[1]).toStrictEqual(jsonPathB); 352 | expect(res[2]).toStrictEqual(jsonPathC); 353 | 354 | // Full resource B path 355 | query = jsonHeroPathAccessor.getItemPath(jsonPathB); 356 | 357 | res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 358 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 359 | ); 360 | expect(res[0]).toStrictEqual(jsonPathB); 361 | expect(res[1]).toStrictEqual(jsonPathA); 362 | expect(res[2]).toStrictEqual(jsonPathC); 363 | 364 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 365 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 366 | ); 367 | expect(res[0]).toStrictEqual(jsonPathB); 368 | expect(res[1]).toStrictEqual(jsonPathA); 369 | expect(res[2]).toStrictEqual(jsonPathC); 370 | }); 371 | 372 | test("compareItemsByScore - label prefix", function () { 373 | const jsonPathA = "$.some.path.fileA"; 374 | const jsonPathB = "$.some.path.other.fileB"; 375 | const jsonPathC = "$.unrelated.some.path.other.fileC"; 376 | 377 | // json path A label 378 | let query = jsonHeroPathAccessor.getItemLabel(jsonPathA); 379 | 380 | let res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 381 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 382 | ); 383 | 384 | expect(res[0]).toStrictEqual(jsonPathA); 385 | expect(res[1]).toStrictEqual(jsonPathB); 386 | expect(res[2]).toStrictEqual(jsonPathC); 387 | 388 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 389 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 390 | ); 391 | expect(res[0]).toStrictEqual(jsonPathA); 392 | expect(res[1]).toStrictEqual(jsonPathB); 393 | expect(res[2]).toStrictEqual(jsonPathC); 394 | 395 | // Full resource B path 396 | query = jsonHeroPathAccessor.getItemLabel(jsonPathB); 397 | 398 | res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 399 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 400 | ); 401 | expect(res[0]).toStrictEqual(jsonPathB); 402 | expect(res[1]).toStrictEqual(jsonPathA); 403 | expect(res[2]).toStrictEqual(jsonPathC); 404 | 405 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 406 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 407 | ); 408 | expect(res[0]).toStrictEqual(jsonPathB); 409 | expect(res[1]).toStrictEqual(jsonPathA); 410 | expect(res[2]).toStrictEqual(jsonPathC); 411 | }); 412 | 413 | test("compareItemsByScore - path scores", function () { 414 | const jsonPathA = "$.some.path.fileA"; 415 | const jsonPathB = "$.some.path.other.fileB"; 416 | const jsonPathC = "$.unrelated.some.path.other.fileC"; 417 | 418 | // json path A part of path 419 | let query = "pathfileA"; 420 | 421 | let res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 422 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 423 | ); 424 | 425 | expect(res[0]).toStrictEqual(jsonPathA); 426 | expect(res[1]).toStrictEqual(jsonPathB); 427 | expect(res[2]).toStrictEqual(jsonPathC); 428 | 429 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 430 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 431 | ); 432 | expect(res[0]).toStrictEqual(jsonPathA); 433 | expect(res[1]).toStrictEqual(jsonPathB); 434 | expect(res[2]).toStrictEqual(jsonPathC); 435 | 436 | // Full resource B path 437 | query = "pathfileB"; 438 | 439 | res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 440 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 441 | ); 442 | expect(res[0]).toStrictEqual(jsonPathB); 443 | expect(res[1]).toStrictEqual(jsonPathA); 444 | expect(res[2]).toStrictEqual(jsonPathC); 445 | 446 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 447 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 448 | ); 449 | expect(res[0]).toStrictEqual(jsonPathB); 450 | expect(res[1]).toStrictEqual(jsonPathA); 451 | expect(res[2]).toStrictEqual(jsonPathC); 452 | }); 453 | 454 | test("compareItemsByScore - prefer shorter labels", function () { 455 | const jsonPathA = "$.some.path.fileA"; 456 | const jsonPathB = "$.some.path.other.fileBLonger"; 457 | const jsonPathC = "$.unrelated.some.path.other.fileC"; 458 | 459 | // json path A part of path 460 | const query = "somepath"; 461 | 462 | let res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 463 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 464 | ); 465 | 466 | expect(res[0]).toStrictEqual(jsonPathA); 467 | expect(res[1]).toStrictEqual(jsonPathB); 468 | expect(res[2]).toStrictEqual(jsonPathC); 469 | 470 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 471 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 472 | ); 473 | expect(res[0]).toStrictEqual(jsonPathA); 474 | expect(res[1]).toStrictEqual(jsonPathB); 475 | expect(res[2]).toStrictEqual(jsonPathC); 476 | }); 477 | 478 | test("compareItemsByScore - prefer non-array items", function () { 479 | const jsonPathA = "$.some.path.fileA.0"; 480 | const jsonPathB = "$.some.path.other.fileB"; 481 | const jsonPathC = "$.unrelated.some.path.other.fileC"; 482 | 483 | // json path A part of path 484 | const query = "path"; 485 | 486 | let res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 487 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 488 | ); 489 | 490 | expect(res[0]).toStrictEqual(jsonPathB); 491 | expect(res[1]).toStrictEqual(jsonPathC); 492 | expect(res[2]).toStrictEqual(jsonPathA); 493 | 494 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 495 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 496 | ); 497 | expect(res[0]).toStrictEqual(jsonPathB); 498 | expect(res[1]).toStrictEqual(jsonPathC); 499 | expect(res[2]).toStrictEqual(jsonPathA); 500 | }); 501 | 502 | test("compareItemsByScore - prefer shorter labels (match on label)", function () { 503 | const jsonPathA = "$.some.path.fileA"; 504 | const jsonPathB = "$.some.path.other.fileBLonger"; 505 | const jsonPathC = "$.unrelated.some.path.other.fileC"; 506 | 507 | // json path A part of path 508 | const query = "file"; 509 | 510 | let res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 511 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 512 | ); 513 | 514 | expect(res[0]).toStrictEqual(jsonPathA); 515 | expect(res[1]).toStrictEqual(jsonPathC); 516 | expect(res[2]).toStrictEqual(jsonPathB); 517 | 518 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 519 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 520 | ); 521 | expect(res[0]).toStrictEqual(jsonPathA); 522 | expect(res[1]).toStrictEqual(jsonPathC); 523 | expect(res[2]).toStrictEqual(jsonPathB); 524 | }); 525 | 526 | test("compareItemsByScore - prefer shorter paths", function () { 527 | const jsonPathA = "$.some.path.fileA"; 528 | const jsonPathB = "$.some.path.other.fileB"; 529 | const jsonPathC = "$.unrelated.some.path.other.fileC"; 530 | 531 | // json path A part of path 532 | const query = "somepath"; 533 | 534 | let res = [jsonPathA, jsonPathB, jsonPathC].sort((p1, p2) => 535 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 536 | ); 537 | 538 | expect(res[0]).toStrictEqual(jsonPathA); 539 | expect(res[1]).toStrictEqual(jsonPathB); 540 | expect(res[2]).toStrictEqual(jsonPathC); 541 | 542 | res = [jsonPathC, jsonPathB, jsonPathA].sort((p1, p2) => 543 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 544 | ); 545 | expect(res[0]).toStrictEqual(jsonPathA); 546 | expect(res[1]).toStrictEqual(jsonPathB); 547 | expect(res[2]).toStrictEqual(jsonPathC); 548 | }); 549 | 550 | test("compareItemsByScore - prefer matches in label over description", function () { 551 | const jsonPathA = "$.parts.quick.arrow-left-dark"; 552 | const jsonPathB = "$.parts.quickopen.quickopen"; 553 | 554 | // json path A part of path 555 | const query = "partsquick"; 556 | 557 | const res = [jsonPathA, jsonPathB].sort((p1, p2) => 558 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 559 | ); 560 | 561 | expect(res[0]).toStrictEqual(jsonPathB); 562 | expect(res[1]).toStrictEqual(jsonPathA); 563 | }); 564 | 565 | test("compareItemsByScore - prefer camel case matches", function () { 566 | const jsonPathA = "$.config.test.nullPointerException"; 567 | const jsonPathB = "$.config.test.nopointerexception"; 568 | 569 | for (const query of ["npe", "NPE"]) { 570 | const res = [jsonPathA, jsonPathB].sort((p1, p2) => 571 | compareItemsByScore(p1, p2, query, true, jsonHeroPathAccessor), 572 | ); 573 | 574 | expect(res[0]).toStrictEqual(jsonPathA); 575 | expect(res[1]).toStrictEqual(jsonPathB); 576 | } 577 | }); 578 | 579 | test("score (fuzzy)", () => { 580 | const target = "HeLlo-World"; 581 | 582 | const scores: FuzzyScore[] = []; 583 | scores.push(_doScore(target, "HelLo-World", true)); // direct case match 584 | scores.push(_doScore(target, "hello-world", true)); // direct mix-case match 585 | scores.push(_doScore(target, "HW", true)); // direct case prefix (multiple) 586 | scores.push(_doScore(target, "hw", true)); // direct mix-case prefix (multiple) 587 | scores.push(_doScore(target, "H", true)); // direct case prefix 588 | scores.push(_doScore(target, "h", true)); // direct mix-case prefix 589 | scores.push(_doScore(target, "W", true)); // direct case word prefix 590 | scores.push(_doScore(target, "Ld", true)); // in-string case match (multiple) 591 | scores.push(_doScore(target, "ld", true)); // in-string mix-case match (consecutive, avoids scattered hit) 592 | scores.push(_doScore(target, "w", true)); // direct mix-case word prefix 593 | scores.push(_doScore(target, "L", true)); // in-string case match 594 | scores.push(_doScore(target, "l", true)); // in-string mix-case match 595 | scores.push(_doScore(target, "4", true)); // no match 596 | 597 | // Assert scoring order 598 | const sortedScores = scores.concat().sort((a, b) => b[0] - a[0]); 599 | expect(scores).toStrictEqual(sortedScores); 600 | }); 601 | 602 | test("score (fuzzy) - json paths", () => { 603 | const target = "xyx.some.path.someKey123"; 604 | 605 | expect(_doScore(target, "xyx.some.path.someKey123", true)[0]).toBeGreaterThan(0); 606 | expect(_doScore(target, "xyx", true)[0]).toBeGreaterThan(0); 607 | expect(_doScore(target, "123", true)[0]).toBeGreaterThan(0); 608 | expect(_doScore(target, "xyx123", true)[0]).toBeGreaterThan(0); 609 | expect(_doScore(target, "xyx123", true)).toMatchInlineSnapshot(` 610 | Array [ 611 | 50, 612 | Array [ 613 | 0, 614 | 1, 615 | 2, 616 | 21, 617 | 22, 618 | 23, 619 | ], 620 | ] 621 | `); 622 | }); 623 | 624 | test("score (non fuzzy)", function () { 625 | const target = "HeLlo-World"; 626 | 627 | expect(_doScore(target, "HelLo-World", false)[0]).toBeGreaterThan(0); 628 | expect(_doScore(target, "HelLo-World", false)[1].length).toStrictEqual("HelLo-World".length); 629 | 630 | expect(_doScore(target, "hello-world", false)[0]).toBeGreaterThan(0); 631 | expect(_doScore(target, "HW", false)[0]).toStrictEqual(0); 632 | expect(_doScore(target, "h", false)[0]).toBeGreaterThan(0); 633 | expect(_doScore(target, "ello", false)[0]).toBeGreaterThan(0); 634 | expect(_doScore(target, "ld", false)[0]).toBeGreaterThan(0); 635 | expect(_doScore(target, "HW", false)[0]).toStrictEqual(0); 636 | expect(_doScore(target, "eo", false)[0]).toStrictEqual(0); 637 | }); 638 | 639 | test("prepareQuery", () => { 640 | expect(prepareQuery(" f*a ").normalized).toStrictEqual("fa"); 641 | expect(prepareQuery("foo Bar").original).toStrictEqual("foo Bar"); 642 | expect(prepareQuery("foo Bar").originalLowercase).toStrictEqual("foo Bar".toLowerCase()); 643 | expect(prepareQuery("foo Bar").normalized).toStrictEqual("fooBar"); 644 | expect(prepareQuery("foo Bar").expectContiguousMatch).toBe(false); // doesn't have quotes in it 645 | expect(prepareQuery("Foo Bar").normalizedLowercase).toStrictEqual("foobar"); 646 | expect(prepareQuery("FooBar").containsPathSeparator).toBe(false); 647 | expect(prepareQuery("foo.bar").containsPathSeparator).toBe(true); 648 | expect(prepareQuery('"foobar"').expectContiguousMatch).toBe(true); 649 | expect(prepareQuery('"hello"').normalized).toStrictEqual("hello"); 650 | 651 | // with spaces 652 | let query = prepareQuery("He*llo World"); 653 | expect(query.original).toStrictEqual("He*llo World"); 654 | expect(query.normalized).toStrictEqual("HelloWorld"); 655 | expect(query.normalizedLowercase).toStrictEqual("HelloWorld".toLowerCase()); 656 | expect(query.values?.length).toStrictEqual(2); 657 | expect(query.values?.[0].original).toStrictEqual("He*llo"); 658 | expect(query.values?.[0].normalized).toStrictEqual("Hello"); 659 | expect(query.values?.[0].normalizedLowercase).toStrictEqual("Hello".toLowerCase()); 660 | expect(query.values?.[1].original).toStrictEqual("World"); 661 | expect(query.values?.[1].normalized).toStrictEqual("World"); 662 | expect(query.values?.[1].normalizedLowercase).toStrictEqual("World".toLowerCase()); 663 | 664 | // with spaces that are empty 665 | query = prepareQuery(" Hello World "); 666 | expect(query.original).toStrictEqual(" Hello World "); 667 | expect(query.originalLowercase).toStrictEqual(" Hello World ".toLowerCase()); 668 | expect(query.normalized).toStrictEqual("HelloWorld"); 669 | expect(query.normalizedLowercase).toStrictEqual("HelloWorld".toLowerCase()); 670 | expect(query.values?.length).toStrictEqual(2); 671 | expect(query.values?.[0].original).toStrictEqual("Hello"); 672 | expect(query.values?.[0].originalLowercase).toStrictEqual("Hello".toLowerCase()); 673 | expect(query.values?.[0].normalized).toStrictEqual("Hello"); 674 | expect(query.values?.[0].normalizedLowercase).toStrictEqual("Hello".toLowerCase()); 675 | expect(query.values?.[1].original).toStrictEqual("World"); 676 | expect(query.values?.[1].originalLowercase).toStrictEqual("World".toLowerCase()); 677 | expect(query.values?.[1].normalized).toStrictEqual("World"); 678 | expect(query.values?.[1].normalizedLowercase).toStrictEqual("World".toLowerCase()); 679 | 680 | expect(prepareQuery("/some/path").pathNormalized).toStrictEqual(".some.path"); 681 | expect(prepareQuery("/some/path").normalized).toStrictEqual(".some.path"); 682 | expect(prepareQuery("/some/path").containsPathSeparator).toStrictEqual(true); 683 | }); 684 | -------------------------------------------------------------------------------- /tests/searching.test.ts: -------------------------------------------------------------------------------- 1 | import { search, SearchResult } from "../src/search"; 2 | import { IItemAccessor, prepareQuery } from "../src/fuzzyScoring"; 3 | import { JsonHeroPathAccessor } from "./utils/jsonHeroAccessor"; 4 | 5 | function doSearch( 6 | items: T[], 7 | query: string, 8 | accessor: IItemAccessor, 9 | allowNonContiguousMatches?: boolean, 10 | ): Array> { 11 | const preparedQuery = prepareQuery(query); 12 | 13 | return search( 14 | items, 15 | preparedQuery, 16 | allowNonContiguousMatches ?? !preparedQuery.expectContiguousMatch, 17 | accessor, 18 | ); 19 | } 20 | 21 | const json = { 22 | data: [ 23 | { 24 | id: "1212092628029698048", 25 | text: "We believe the best future version of our API will come from building it with YOU. Here’s to another great year with everyone who builds on the Twitter platform. We can’t wait to continue working with you in the new year. https://t.co/yvxdK6aOo2", 26 | possibly_sensitive: false, 27 | referenced_tweets: [{ type: "replied_to", id: "1212092627178287104" }], 28 | entities: { 29 | urls: [ 30 | { 31 | start: 222, 32 | end: 245, 33 | url: "https://t.co/yvxdK6aOo2", 34 | expanded_url: "https://twitter.com/LovesNandos/status/1211797914437259264/photo/1", 35 | display_url: "pic.twitter.com/yvxdK6aOo2", 36 | }, 37 | ], 38 | annotations: [ 39 | { start: 144, end: 150, probability: 0.626, type: "Product", normalized_text: "Twitter" }, 40 | ], 41 | }, 42 | author_id: "2244994945", 43 | public_metrics: { retweet_count: 8, reply_count: 2, like_count: 40, quote_count: 1 }, 44 | lang: "en", 45 | created_at: "2019-12-31T19:26:16.000Z", 46 | source: "Twitter Web App", 47 | in_reply_to_user_id: "2244994945", 48 | attachments: { media_keys: ["16_1211797899316740096"] }, 49 | context_annotations: [ 50 | { 51 | domain: { 52 | id: "119", 53 | name: "Holiday", 54 | description: "Holidays like Christmas or Halloween", 55 | }, 56 | entity: { id: "1186637514896920576", name: " New Years Eve" }, 57 | }, 58 | { 59 | domain: { 60 | id: "119", 61 | name: "Holiday", 62 | description: "Holidays like Christmas or Halloween", 63 | }, 64 | entity: { 65 | id: "1206982436287963136", 66 | name: "Happy New Year: It’s finally 2020 everywhere!", 67 | description: 68 | "Catch fireworks and other celebrations as people across the globe enter the new year.\nPhoto via @GettyImages ", 69 | }, 70 | }, 71 | { 72 | domain: { 73 | id: "46", 74 | name: "Brand Category", 75 | description: "Categories within Brand Verticals that narrow down the scope of Brands", 76 | }, 77 | entity: { id: "781974596752842752", name: "Services" }, 78 | }, 79 | { 80 | domain: { id: "47", name: "Brand", description: "Brands and Companies" }, 81 | entity: { id: "10045225402", name: "Twitter" }, 82 | }, 83 | { 84 | domain: { 85 | id: "119", 86 | name: "Holiday", 87 | description: "Holidays like Christmas or Halloween", 88 | }, 89 | entity: { 90 | id: "1206982436287963136", 91 | name: "Happy New Year: It’s finally 2020 everywhere!", 92 | description: 93 | "Catch fireworks and other celebrations as people across the globe enter the new year.\nPhoto via @GettyImages ", 94 | }, 95 | }, 96 | ], 97 | }, 98 | ], 99 | includes: { 100 | tweets: [ 101 | { 102 | possibly_sensitive: false, 103 | referenced_tweets: [{ type: "replied_to", id: "1212092626247110657" }], 104 | text: "These launches would not be possible without the feedback you provided along the way, so THANK YOU to everyone who has contributed your time and ideas. Have more feedback? Let us know ⬇️ https://t.co/Vxp4UKnuJ9", 105 | entities: { 106 | urls: [ 107 | { 108 | start: 187, 109 | end: 210, 110 | url: "https://t.co/Vxp4UKnuJ9", 111 | expanded_url: 112 | "https://twitterdevfeedback.uservoice.com/forums/921790-twitter-developer-labs", 113 | display_url: "twitterdevfeedback.uservoice.com/forums/921790-…", 114 | images: [ 115 | { 116 | url: "https://pbs.twimg.com/news_img/1261301555787108354/9yR4UVsa?format=png&name=orig", 117 | width: 100, 118 | height: 100, 119 | }, 120 | { 121 | url: "https://pbs.twimg.com/news_img/1261301555787108354/9yR4UVsa?format=png&name=150x150", 122 | width: 100, 123 | height: 100, 124 | }, 125 | ], 126 | status: 200, 127 | title: "Twitter Developer Feedback", 128 | description: "Share your feedback for the Twitter developer platform", 129 | unwound_url: 130 | "https://twitterdevfeedback.uservoice.com/forums/921790-twitter-developer-labs", 131 | }, 132 | ], 133 | }, 134 | author_id: "2244994945", 135 | public_metrics: { retweet_count: 3, reply_count: 1, like_count: 17, quote_count: 0 }, 136 | lang: "en", 137 | created_at: "2019-12-31T19:26:16.000Z", 138 | source: "Twitter Web App", 139 | in_reply_to_user_id: "2244994945", 140 | id: "1212092627178287104", 141 | }, 142 | ], 143 | }, 144 | } as unknown; 145 | 146 | const jsonHeroPathAccessor = new JsonHeroPathAccessor(json); 147 | 148 | const items = jsonHeroPathAccessor.createItems(); 149 | 150 | test("searching results", () => { 151 | const query = "tweet"; 152 | const results = doSearch(items, query, jsonHeroPathAccessor, true); 153 | 154 | expect(results).toMatchInlineSnapshot(` 155 | Array [ 156 | Object { 157 | "item": "$.includes.tweets", 158 | "score": Object { 159 | "description": "includes", 160 | "formattedValue": undefined, 161 | "label": "tweets", 162 | "labelMatch": Array [ 163 | Object { 164 | "end": 5, 165 | "start": 0, 166 | }, 167 | ], 168 | "rawValue": undefined, 169 | "score": 131223, 170 | }, 171 | }, 172 | Object { 173 | "item": "$.data.0.referenced_tweets", 174 | "score": Object { 175 | "description": "data.0", 176 | "formattedValue": undefined, 177 | "label": "referenced_tweets", 178 | "labelMatch": Array [ 179 | Object { 180 | "end": 16, 181 | "start": 11, 182 | }, 183 | ], 184 | "rawValue": undefined, 185 | "score": 65600, 186 | }, 187 | }, 188 | Object { 189 | "item": "$.includes.tweets.0.referenced_tweets", 190 | "score": Object { 191 | "description": "includes.tweets.0", 192 | "formattedValue": undefined, 193 | "label": "referenced_tweets", 194 | "labelMatch": Array [ 195 | Object { 196 | "end": 16, 197 | "start": 11, 198 | }, 199 | ], 200 | "rawValue": undefined, 201 | "score": 65600, 202 | }, 203 | }, 204 | Object { 205 | "item": "$.data.0.public_metrics.retweet_count", 206 | "score": Object { 207 | "description": "data.0.public_metrics", 208 | "formattedValue": "8", 209 | "label": "retweet_count", 210 | "labelMatch": Array [ 211 | Object { 212 | "end": 7, 213 | "start": 2, 214 | }, 215 | ], 216 | "rawValue": "8", 217 | "score": 65596, 218 | }, 219 | }, 220 | Object { 221 | "item": "$.includes.tweets.0.public_metrics.retweet_count", 222 | "score": Object { 223 | "description": "includes.tweets.0.public_metrics", 224 | "formattedValue": "3", 225 | "label": "retweet_count", 226 | "labelMatch": Array [ 227 | Object { 228 | "end": 7, 229 | "start": 2, 230 | }, 231 | ], 232 | "rawValue": "3", 233 | "score": 65596, 234 | }, 235 | }, 236 | Object { 237 | "item": "$.includes.tweets.0.text", 238 | "score": Object { 239 | "description": "includes.tweets.0", 240 | "descriptionMatch": Array [ 241 | Object { 242 | "end": 14, 243 | "start": 9, 244 | }, 245 | ], 246 | "formattedValue": "These launches would not be possible without the feedback you provided along the way, so THANK YOU to everyone who has contributed your time and ideas. Have more feedback? Let us know ⬇️ https://t.co/Vxp4UKnuJ9", 247 | "label": "text", 248 | "labelMatch": Array [], 249 | "rawValue": "These launches would not be possible without the feedback you provided along the way, so THANK YOU to everyone who has contributed your time and ideas. Have more feedback? Let us know ⬇️ https://t.co/Vxp4UKnuJ9", 250 | "rawValueMatch": Array [ 251 | Object { 252 | "end": 1, 253 | "start": 0, 254 | }, 255 | Object { 256 | "end": 38, 257 | "start": 37, 258 | }, 259 | Object { 260 | "end": 52, 261 | "start": 50, 262 | }, 263 | Object { 264 | "end": 90, 265 | "start": 89, 266 | }, 267 | ], 268 | "score": 88, 269 | }, 270 | }, 271 | Object { 272 | "item": "$.includes.tweets.0.entities.urls.0.unwound_url", 273 | "score": Object { 274 | "description": "includes.tweets.0.entities.urls.0", 275 | "descriptionMatch": Array [ 276 | Object { 277 | "end": 14, 278 | "start": 9, 279 | }, 280 | ], 281 | "formattedValue": "https://twitterdevfeedback.uservoice.com/forums/921790-twitter-developer-labs", 282 | "label": "unwound_url", 283 | "labelMatch": Array [], 284 | "rawValue": "https://twitterdevfeedback.uservoice.com/forums/921790-twitter-developer-labs", 285 | "rawValueMatch": Array [ 286 | Object { 287 | "end": 10, 288 | "start": 8, 289 | }, 290 | Object { 291 | "end": 21, 292 | "start": 19, 293 | }, 294 | Object { 295 | "end": 60, 296 | "start": 59, 297 | }, 298 | ], 299 | "score": 85, 300 | }, 301 | }, 302 | Object { 303 | "item": "$.includes.tweets.0.entities.urls.0.expanded_url", 304 | "score": Object { 305 | "description": "includes.tweets.0.entities.urls.0", 306 | "descriptionMatch": Array [ 307 | Object { 308 | "end": 14, 309 | "start": 9, 310 | }, 311 | ], 312 | "formattedValue": "https://twitterdevfeedback.uservoice.com/forums/921790-twitter-developer-labs", 313 | "label": "expanded_url", 314 | "labelMatch": Array [], 315 | "rawValue": "https://twitterdevfeedback.uservoice.com/forums/921790-twitter-developer-labs", 316 | "rawValueMatch": Array [ 317 | Object { 318 | "end": 10, 319 | "start": 8, 320 | }, 321 | Object { 322 | "end": 21, 323 | "start": 19, 324 | }, 325 | Object { 326 | "end": 60, 327 | "start": 59, 328 | }, 329 | ], 330 | "score": 85, 331 | }, 332 | }, 333 | Object { 334 | "item": "$.includes.tweets.0.entities.urls.0.description", 335 | "score": Object { 336 | "description": "includes.tweets.0.entities.urls.0", 337 | "descriptionMatch": Array [ 338 | Object { 339 | "end": 14, 340 | "start": 9, 341 | }, 342 | ], 343 | "formattedValue": "Share your feedback for the Twitter developer platform", 344 | "label": "description", 345 | "labelMatch": Array [], 346 | "rawValue": "Share your feedback for the Twitter developer platform", 347 | "rawValueMatch": Array [ 348 | Object { 349 | "end": 30, 350 | "start": 28, 351 | }, 352 | Object { 353 | "end": 40, 354 | "start": 39, 355 | }, 356 | Object { 357 | "end": 44, 358 | "start": 43, 359 | }, 360 | Object { 361 | "end": 50, 362 | "start": 49, 363 | }, 364 | ], 365 | "score": 81, 366 | }, 367 | }, 368 | Object { 369 | "item": "$.includes.tweets.0.id", 370 | "score": Object { 371 | "description": "includes.tweets.0", 372 | "descriptionMatch": Array [ 373 | Object { 374 | "end": 14, 375 | "start": 9, 376 | }, 377 | ], 378 | "formattedValue": "1212092627178287104", 379 | "label": "id", 380 | "labelMatch": Array [], 381 | "rawValue": "1212092627178287104", 382 | "score": 65, 383 | }, 384 | }, 385 | Object { 386 | "item": "$.includes.tweets.0.lang", 387 | "score": Object { 388 | "description": "includes.tweets.0", 389 | "descriptionMatch": Array [ 390 | Object { 391 | "end": 14, 392 | "start": 9, 393 | }, 394 | ], 395 | "formattedValue": "en", 396 | "label": "lang", 397 | "labelMatch": Array [], 398 | "rawValue": "en", 399 | "score": 65, 400 | }, 401 | }, 402 | Object { 403 | "item": "$.includes.tweets.0.source", 404 | "score": Object { 405 | "description": "includes.tweets.0", 406 | "descriptionMatch": Array [ 407 | Object { 408 | "end": 14, 409 | "start": 9, 410 | }, 411 | ], 412 | "formattedValue": "Twitter Web App", 413 | "label": "source", 414 | "labelMatch": Array [], 415 | "rawValue": "Twitter Web App", 416 | "score": 65, 417 | }, 418 | }, 419 | Object { 420 | "item": "$.includes.tweets.0.entities", 421 | "score": Object { 422 | "description": "includes.tweets.0", 423 | "descriptionMatch": Array [ 424 | Object { 425 | "end": 14, 426 | "start": 9, 427 | }, 428 | ], 429 | "formattedValue": undefined, 430 | "label": "entities", 431 | "labelMatch": Array [], 432 | "rawValue": undefined, 433 | "score": 65, 434 | }, 435 | }, 436 | Object { 437 | "item": "$.includes.tweets.0.author_id", 438 | "score": Object { 439 | "description": "includes.tweets.0", 440 | "descriptionMatch": Array [ 441 | Object { 442 | "end": 14, 443 | "start": 9, 444 | }, 445 | ], 446 | "formattedValue": "2244994945", 447 | "label": "author_id", 448 | "labelMatch": Array [], 449 | "rawValue": "2244994945", 450 | "score": 65, 451 | }, 452 | }, 453 | Object { 454 | "item": "$.includes.tweets.0.created_at", 455 | "score": Object { 456 | "description": "includes.tweets.0", 457 | "descriptionMatch": Array [ 458 | Object { 459 | "end": 14, 460 | "start": 9, 461 | }, 462 | ], 463 | "formattedValue": "Tue Dec 31 2019 19:26:16 GMT+0000 (Greenwich Mean Time)", 464 | "label": "created_at", 465 | "labelMatch": Array [], 466 | "rawValue": "2019-12-31T19:26:16.000Z", 467 | "score": 65, 468 | }, 469 | }, 470 | Object { 471 | "item": "$.includes.tweets.0.entities.urls", 472 | "score": Object { 473 | "description": "includes.tweets.0.entities", 474 | "descriptionMatch": Array [ 475 | Object { 476 | "end": 14, 477 | "start": 9, 478 | }, 479 | ], 480 | "formattedValue": undefined, 481 | "label": "urls", 482 | "labelMatch": Array [], 483 | "rawValue": undefined, 484 | "score": 65, 485 | }, 486 | }, 487 | Object { 488 | "item": "$.includes.tweets.0.public_metrics", 489 | "score": Object { 490 | "description": "includes.tweets.0", 491 | "descriptionMatch": Array [ 492 | Object { 493 | "end": 14, 494 | "start": 9, 495 | }, 496 | ], 497 | "formattedValue": undefined, 498 | "label": "public_metrics", 499 | "labelMatch": Array [], 500 | "rawValue": undefined, 501 | "score": 65, 502 | }, 503 | }, 504 | Object { 505 | "item": "$.includes.tweets.0.possibly_sensitive", 506 | "score": Object { 507 | "description": "includes.tweets.0", 508 | "descriptionMatch": Array [ 509 | Object { 510 | "end": 14, 511 | "start": 9, 512 | }, 513 | ], 514 | "formattedValue": "false", 515 | "label": "possibly_sensitive", 516 | "labelMatch": Array [], 517 | "rawValue": "false", 518 | "score": 65, 519 | }, 520 | }, 521 | Object { 522 | "item": "$.includes.tweets.0.entities.urls.0.end", 523 | "score": Object { 524 | "description": "includes.tweets.0.entities.urls.0", 525 | "descriptionMatch": Array [ 526 | Object { 527 | "end": 14, 528 | "start": 9, 529 | }, 530 | ], 531 | "formattedValue": "210", 532 | "label": "end", 533 | "labelMatch": Array [], 534 | "rawValue": "210", 535 | "score": 65, 536 | }, 537 | }, 538 | Object { 539 | "item": "$.includes.tweets.0.in_reply_to_user_id", 540 | "score": Object { 541 | "description": "includes.tweets.0", 542 | "descriptionMatch": Array [ 543 | Object { 544 | "end": 14, 545 | "start": 9, 546 | }, 547 | ], 548 | "formattedValue": "2244994945", 549 | "label": "in_reply_to_user_id", 550 | "labelMatch": Array [], 551 | "rawValue": "2244994945", 552 | "score": 65, 553 | }, 554 | }, 555 | Object { 556 | "item": "$.includes.tweets.0.entities.urls.0.url", 557 | "score": Object { 558 | "description": "includes.tweets.0.entities.urls.0", 559 | "descriptionMatch": Array [ 560 | Object { 561 | "end": 14, 562 | "start": 9, 563 | }, 564 | ], 565 | "formattedValue": "https://t.co/Vxp4UKnuJ9", 566 | "label": "url", 567 | "labelMatch": Array [], 568 | "rawValue": "https://t.co/Vxp4UKnuJ9", 569 | "score": 65, 570 | }, 571 | }, 572 | Object { 573 | "item": "$.includes.tweets.0.entities.urls.0.start", 574 | "score": Object { 575 | "description": "includes.tweets.0.entities.urls.0", 576 | "descriptionMatch": Array [ 577 | Object { 578 | "end": 14, 579 | "start": 9, 580 | }, 581 | ], 582 | "formattedValue": "187", 583 | "label": "start", 584 | "labelMatch": Array [], 585 | "rawValue": "187", 586 | "score": 65, 587 | }, 588 | }, 589 | Object { 590 | "item": "$.includes.tweets.0.entities.urls.0.title", 591 | "score": Object { 592 | "description": "includes.tweets.0.entities.urls.0", 593 | "descriptionMatch": Array [ 594 | Object { 595 | "end": 14, 596 | "start": 9, 597 | }, 598 | ], 599 | "formattedValue": "Twitter Developer Feedback", 600 | "label": "title", 601 | "labelMatch": Array [], 602 | "rawValue": "Twitter Developer Feedback", 603 | "score": 65, 604 | }, 605 | }, 606 | Object { 607 | "item": "$.includes.tweets.0.referenced_tweets.0.id", 608 | "score": Object { 609 | "description": "includes.tweets.0.referenced_tweets.0", 610 | "descriptionMatch": Array [ 611 | Object { 612 | "end": 14, 613 | "start": 9, 614 | }, 615 | ], 616 | "formattedValue": "1212092626247110657", 617 | "label": "id", 618 | "labelMatch": Array [], 619 | "rawValue": "1212092626247110657", 620 | "score": 65, 621 | }, 622 | }, 623 | Object { 624 | "item": "$.includes.tweets.0.entities.urls.0.images", 625 | "score": Object { 626 | "description": "includes.tweets.0.entities.urls.0", 627 | "descriptionMatch": Array [ 628 | Object { 629 | "end": 14, 630 | "start": 9, 631 | }, 632 | ], 633 | "formattedValue": undefined, 634 | "label": "images", 635 | "labelMatch": Array [], 636 | "rawValue": undefined, 637 | "score": 65, 638 | }, 639 | }, 640 | Object { 641 | "item": "$.includes.tweets.0.entities.urls.0.status", 642 | "score": Object { 643 | "description": "includes.tweets.0.entities.urls.0", 644 | "descriptionMatch": Array [ 645 | Object { 646 | "end": 14, 647 | "start": 9, 648 | }, 649 | ], 650 | "formattedValue": "200", 651 | "label": "status", 652 | "labelMatch": Array [], 653 | "rawValue": "200", 654 | "score": 65, 655 | }, 656 | }, 657 | Object { 658 | "item": "$.includes.tweets.0.referenced_tweets.0.type", 659 | "score": Object { 660 | "description": "includes.tweets.0.referenced_tweets.0", 661 | "descriptionMatch": Array [ 662 | Object { 663 | "end": 14, 664 | "start": 9, 665 | }, 666 | ], 667 | "formattedValue": "replied_to", 668 | "label": "type", 669 | "labelMatch": Array [], 670 | "rawValue": "replied_to", 671 | "score": 65, 672 | }, 673 | }, 674 | Object { 675 | "item": "$.includes.tweets.0.public_metrics.like_count", 676 | "score": Object { 677 | "description": "includes.tweets.0.public_metrics", 678 | "descriptionMatch": Array [ 679 | Object { 680 | "end": 14, 681 | "start": 9, 682 | }, 683 | ], 684 | "formattedValue": "17", 685 | "label": "like_count", 686 | "labelMatch": Array [], 687 | "rawValue": "17", 688 | "score": 65, 689 | }, 690 | }, 691 | Object { 692 | "item": "$.includes.tweets.0.public_metrics.quote_count", 693 | "score": Object { 694 | "description": "includes.tweets.0.public_metrics", 695 | "descriptionMatch": Array [ 696 | Object { 697 | "end": 14, 698 | "start": 9, 699 | }, 700 | ], 701 | "formattedValue": "0", 702 | "label": "quote_count", 703 | "labelMatch": Array [], 704 | "rawValue": "0", 705 | "score": 65, 706 | }, 707 | }, 708 | Object { 709 | "item": "$.includes.tweets.0.public_metrics.reply_count", 710 | "score": Object { 711 | "description": "includes.tweets.0.public_metrics", 712 | "descriptionMatch": Array [ 713 | Object { 714 | "end": 14, 715 | "start": 9, 716 | }, 717 | ], 718 | "formattedValue": "1", 719 | "label": "reply_count", 720 | "labelMatch": Array [], 721 | "rawValue": "1", 722 | "score": 65, 723 | }, 724 | }, 725 | Object { 726 | "item": "$.includes.tweets.0.entities.urls.0.display_url", 727 | "score": Object { 728 | "description": "includes.tweets.0.entities.urls.0", 729 | "descriptionMatch": Array [ 730 | Object { 731 | "end": 14, 732 | "start": 9, 733 | }, 734 | ], 735 | "formattedValue": "twitterdevfeedback.uservoice.com/forums/921790-…", 736 | "label": "display_url", 737 | "labelMatch": Array [], 738 | "rawValue": "twitterdevfeedback.uservoice.com/forums/921790-…", 739 | "score": 65, 740 | }, 741 | }, 742 | Object { 743 | "item": "$.includes.tweets.0.entities.urls.0.images.0.url", 744 | "score": Object { 745 | "description": "includes.tweets.0.entities.urls.0.images.0", 746 | "descriptionMatch": Array [ 747 | Object { 748 | "end": 14, 749 | "start": 9, 750 | }, 751 | ], 752 | "formattedValue": "https://pbs.twimg.com/news_img/1261301555787108354/9yR4UVsa?format=png&name=orig", 753 | "label": "url", 754 | "labelMatch": Array [], 755 | "rawValue": "https://pbs.twimg.com/news_img/1261301555787108354/9yR4UVsa?format=png&name=orig", 756 | "score": 65, 757 | }, 758 | }, 759 | Object { 760 | "item": "$.includes.tweets.0.entities.urls.0.images.1.url", 761 | "score": Object { 762 | "description": "includes.tweets.0.entities.urls.0.images.1", 763 | "descriptionMatch": Array [ 764 | Object { 765 | "end": 14, 766 | "start": 9, 767 | }, 768 | ], 769 | "formattedValue": "https://pbs.twimg.com/news_img/1261301555787108354/9yR4UVsa?format=png&name=150x150", 770 | "label": "url", 771 | "labelMatch": Array [], 772 | "rawValue": "https://pbs.twimg.com/news_img/1261301555787108354/9yR4UVsa?format=png&name=150x150", 773 | "score": 65, 774 | }, 775 | }, 776 | Object { 777 | "item": "$.includes.tweets.0.entities.urls.0.images.0.width", 778 | "score": Object { 779 | "description": "includes.tweets.0.entities.urls.0.images.0", 780 | "descriptionMatch": Array [ 781 | Object { 782 | "end": 14, 783 | "start": 9, 784 | }, 785 | ], 786 | "formattedValue": "100", 787 | "label": "width", 788 | "labelMatch": Array [], 789 | "rawValue": "100", 790 | "score": 65, 791 | }, 792 | }, 793 | Object { 794 | "item": "$.includes.tweets.0.entities.urls.0.images.1.width", 795 | "score": Object { 796 | "description": "includes.tweets.0.entities.urls.0.images.1", 797 | "descriptionMatch": Array [ 798 | Object { 799 | "end": 14, 800 | "start": 9, 801 | }, 802 | ], 803 | "formattedValue": "100", 804 | "label": "width", 805 | "labelMatch": Array [], 806 | "rawValue": "100", 807 | "score": 65, 808 | }, 809 | }, 810 | Object { 811 | "item": "$.includes.tweets.0.entities.urls.0.images.0.height", 812 | "score": Object { 813 | "description": "includes.tweets.0.entities.urls.0.images.0", 814 | "descriptionMatch": Array [ 815 | Object { 816 | "end": 14, 817 | "start": 9, 818 | }, 819 | ], 820 | "formattedValue": "100", 821 | "label": "height", 822 | "labelMatch": Array [], 823 | "rawValue": "100", 824 | "score": 65, 825 | }, 826 | }, 827 | Object { 828 | "item": "$.includes.tweets.0.entities.urls.0.images.1.height", 829 | "score": Object { 830 | "description": "includes.tweets.0.entities.urls.0.images.1", 831 | "descriptionMatch": Array [ 832 | Object { 833 | "end": 14, 834 | "start": 9, 835 | }, 836 | ], 837 | "formattedValue": "100", 838 | "label": "height", 839 | "labelMatch": Array [], 840 | "rawValue": "100", 841 | "score": 65, 842 | }, 843 | }, 844 | Object { 845 | "item": "$.includes.tweets.0", 846 | "score": Object { 847 | "description": "includes.tweets", 848 | "descriptionMatch": Array [ 849 | Object { 850 | "end": 14, 851 | "start": 9, 852 | }, 853 | ], 854 | "formattedValue": undefined, 855 | "label": "0", 856 | "labelMatch": Array [], 857 | "rawValue": undefined, 858 | "score": 65, 859 | }, 860 | }, 861 | Object { 862 | "item": "$.includes.tweets.0.entities.urls.0", 863 | "score": Object { 864 | "description": "includes.tweets.0.entities.urls", 865 | "descriptionMatch": Array [ 866 | Object { 867 | "end": 14, 868 | "start": 9, 869 | }, 870 | ], 871 | "formattedValue": undefined, 872 | "label": "0", 873 | "labelMatch": Array [], 874 | "rawValue": undefined, 875 | "score": 65, 876 | }, 877 | }, 878 | Object { 879 | "item": "$.includes.tweets.0.referenced_tweets.0", 880 | "score": Object { 881 | "description": "includes.tweets.0.referenced_tweets", 882 | "descriptionMatch": Array [ 883 | Object { 884 | "end": 14, 885 | "start": 9, 886 | }, 887 | ], 888 | "formattedValue": undefined, 889 | "label": "0", 890 | "labelMatch": Array [], 891 | "rawValue": undefined, 892 | "score": 65, 893 | }, 894 | }, 895 | Object { 896 | "item": "$.includes.tweets.0.entities.urls.0.images.0", 897 | "score": Object { 898 | "description": "includes.tweets.0.entities.urls.0.images", 899 | "descriptionMatch": Array [ 900 | Object { 901 | "end": 14, 902 | "start": 9, 903 | }, 904 | ], 905 | "formattedValue": undefined, 906 | "label": "0", 907 | "labelMatch": Array [], 908 | "rawValue": undefined, 909 | "score": 65, 910 | }, 911 | }, 912 | Object { 913 | "item": "$.includes.tweets.0.entities.urls.0.images.1", 914 | "score": Object { 915 | "description": "includes.tweets.0.entities.urls.0.images", 916 | "descriptionMatch": Array [ 917 | Object { 918 | "end": 14, 919 | "start": 9, 920 | }, 921 | ], 922 | "formattedValue": undefined, 923 | "label": "1", 924 | "labelMatch": Array [], 925 | "rawValue": undefined, 926 | "score": 65, 927 | }, 928 | }, 929 | Object { 930 | "item": "$.data.0.referenced_tweets.0.id", 931 | "score": Object { 932 | "description": "data.0.referenced_tweets.0", 933 | "descriptionMatch": Array [ 934 | Object { 935 | "end": 23, 936 | "start": 18, 937 | }, 938 | ], 939 | "formattedValue": "1212092627178287104", 940 | "label": "id", 941 | "labelMatch": Array [], 942 | "rawValue": "1212092627178287104", 943 | "score": 64, 944 | }, 945 | }, 946 | Object { 947 | "item": "$.data.0.referenced_tweets.0.type", 948 | "score": Object { 949 | "description": "data.0.referenced_tweets.0", 950 | "descriptionMatch": Array [ 951 | Object { 952 | "end": 23, 953 | "start": 18, 954 | }, 955 | ], 956 | "formattedValue": "replied_to", 957 | "label": "type", 958 | "labelMatch": Array [], 959 | "rawValue": "replied_to", 960 | "score": 64, 961 | }, 962 | }, 963 | Object { 964 | "item": "$.data.0.referenced_tweets.0", 965 | "score": Object { 966 | "description": "data.0.referenced_tweets", 967 | "descriptionMatch": Array [ 968 | Object { 969 | "end": 23, 970 | "start": 18, 971 | }, 972 | ], 973 | "formattedValue": undefined, 974 | "label": "0", 975 | "labelMatch": Array [], 976 | "rawValue": undefined, 977 | "score": 64, 978 | }, 979 | }, 980 | Object { 981 | "item": "$.data.0.text", 982 | "score": Object { 983 | "description": "data.0", 984 | "formattedValue": "We believe the best future version of our API will come from building it with YOU. Here’s to another great year with everyone who builds on the Twitter platform. We can’t wait to continue working with you in the new year. https://t.co/yvxdK6aOo2", 985 | "label": "text", 986 | "rawValue": "We believe the best future version of our API will come from building it with YOU. Here’s to another great year with everyone who builds on the Twitter platform. We can’t wait to continue working with you in the new year. https://t.co/yvxdK6aOo2", 987 | "rawValueMatch": Array [ 988 | Object { 989 | "end": 146, 990 | "start": 144, 991 | }, 992 | Object { 993 | "end": 214, 994 | "start": 213, 995 | }, 996 | Object { 997 | "end": 218, 998 | "start": 217, 999 | }, 1000 | Object { 1001 | "end": 231, 1002 | "start": 230, 1003 | }, 1004 | ], 1005 | "score": 16, 1006 | }, 1007 | }, 1008 | Object { 1009 | "item": "$.data.0.entities.urls.0.expanded_url", 1010 | "score": Object { 1011 | "description": "data.0.entities.urls.0", 1012 | "formattedValue": "https://twitter.com/LovesNandos/status/1211797914437259264/photo/1", 1013 | "label": "expanded_url", 1014 | "rawValue": "https://twitter.com/LovesNandos/status/1211797914437259264/photo/1", 1015 | "rawValueMatch": Array [ 1016 | Object { 1017 | "end": 10, 1018 | "start": 8, 1019 | }, 1020 | Object { 1021 | "end": 14, 1022 | "start": 13, 1023 | }, 1024 | Object { 1025 | "end": 24, 1026 | "start": 23, 1027 | }, 1028 | Object { 1029 | "end": 63, 1030 | "start": 62, 1031 | }, 1032 | ], 1033 | "score": 15, 1034 | }, 1035 | }, 1036 | Object { 1037 | "item": "$.data.0.context_annotations.1.entity.description", 1038 | "score": Object { 1039 | "description": "data.0.context_annotations.1.entity", 1040 | "formattedValue": "Catch fireworks and other celebrations as people across the globe enter the new year. 1041 | Photo via @GettyImages ", 1042 | "label": "description", 1043 | "rawValue": "Catch fireworks and other celebrations as people across the globe enter the new year. 1044 | Photo via @GettyImages ", 1045 | "rawValueMatch": Array [ 1046 | Object { 1047 | "end": 73, 1048 | "start": 72, 1049 | }, 1050 | Object { 1051 | "end": 79, 1052 | "start": 78, 1053 | }, 1054 | Object { 1055 | "end": 82, 1056 | "start": 81, 1057 | }, 1058 | Object { 1059 | "end": 100, 1060 | "start": 98, 1061 | }, 1062 | ], 1063 | "score": 15, 1064 | }, 1065 | }, 1066 | Object { 1067 | "item": "$.data.0.context_annotations.4.entity.description", 1068 | "score": Object { 1069 | "description": "data.0.context_annotations.4.entity", 1070 | "formattedValue": "Catch fireworks and other celebrations as people across the globe enter the new year. 1071 | Photo via @GettyImages ", 1072 | "label": "description", 1073 | "rawValue": "Catch fireworks and other celebrations as people across the globe enter the new year. 1074 | Photo via @GettyImages ", 1075 | "rawValueMatch": Array [ 1076 | Object { 1077 | "end": 73, 1078 | "start": 72, 1079 | }, 1080 | Object { 1081 | "end": 79, 1082 | "start": 78, 1083 | }, 1084 | Object { 1085 | "end": 82, 1086 | "start": 81, 1087 | }, 1088 | Object { 1089 | "end": 100, 1090 | "start": 98, 1091 | }, 1092 | ], 1093 | "score": 15, 1094 | }, 1095 | }, 1096 | ] 1097 | `); 1098 | }); 1099 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | process.env.TZ = "GMT"; 3 | }; 4 | -------------------------------------------------------------------------------- /tests/utils/getAllPaths.ts: -------------------------------------------------------------------------------- 1 | import { JSONHeroPath } from "@jsonhero/path"; 2 | 3 | export function getAllPaths(json: unknown): Array { 4 | const paths: Array = []; 5 | 6 | function walk(json: unknown, path: JSONHeroPath) { 7 | paths.push(path); 8 | 9 | if (Array.isArray(json)) { 10 | for (let i = 0; i < json.length; i++) { 11 | walk(json[i], path.child(i.toString())); 12 | } 13 | } else if (typeof json === "object" && json !== null) { 14 | for (const key of Object.keys(json)) { 15 | walk(json[key as keyof typeof json], path.child(key)); 16 | } 17 | } 18 | } 19 | 20 | walk(json, new JSONHeroPath("$")); 21 | 22 | return paths; 23 | } 24 | -------------------------------------------------------------------------------- /tests/utils/jsonHeroAccessor.ts: -------------------------------------------------------------------------------- 1 | import { inferType } from "@jsonhero/json-infer-types"; 2 | import { JSONHeroPath } from "@jsonhero/path"; 3 | import { IItemAccessor } from "../../src/fuzzyScoring"; 4 | import { getAllPaths } from "./getAllPaths"; 5 | 6 | export class JsonHeroPathAccessor implements IItemAccessor { 7 | json: unknown; 8 | 9 | constructor(json: unknown) { 10 | this.json = json; 11 | } 12 | 13 | createItems(): string[] { 14 | return getAllPaths(this.json).map((path) => path.toString()); 15 | } 16 | 17 | getIsArrayItem(item: string): boolean { 18 | const path = new JSONHeroPath(item); 19 | 20 | return path.lastComponent!.isArray; 21 | } 22 | 23 | getItemLabel(item: string): string { 24 | return new JSONHeroPath(item).lastComponent!.toString(); 25 | } 26 | 27 | getItemDescription(item: string): string { 28 | // Get all but the first and last component 29 | const components = new JSONHeroPath(item).components.slice(1, -1); 30 | 31 | return components.map((c) => c.toString()).join("."); 32 | } 33 | 34 | getItemPath(item: string): string { 35 | // Get all but the first component 36 | const components = new JSONHeroPath(item).components.slice(1); 37 | 38 | return components.map((c) => c.toString()).join("."); 39 | } 40 | 41 | getRawValue(item: string): string | undefined { 42 | const inferred = inferType(new JSONHeroPath(item).first(this.json)); 43 | 44 | switch (inferred.name) { 45 | case "string": 46 | return inferred.value; 47 | case "int": 48 | case "float": 49 | return inferred.value.toString(); 50 | case "null": 51 | return "null"; 52 | case "bool": 53 | return inferred.value ? "true" : "false"; 54 | default: 55 | return; 56 | } 57 | } 58 | 59 | getFormattedValue(item: string): string | undefined { 60 | const inferred = inferType(new JSONHeroPath(item).first(this.json)); 61 | 62 | switch (inferred.name) { 63 | case "string": { 64 | if (!inferred.format) { 65 | return inferred.value; 66 | } 67 | 68 | switch (inferred.format.name) { 69 | case "datetime": { 70 | const date = new Date(inferred.value); 71 | 72 | return date.toString(); 73 | } 74 | default: { 75 | return inferred.value; 76 | } 77 | } 78 | } 79 | case "int": 80 | case "float": 81 | return inferred.value.toString(); 82 | case "null": 83 | return "null"; 84 | case "bool": 85 | return inferred.value ? "true" : "false"; 86 | default: 87 | return; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "preserveConstEnums": true, 5 | "outDir": "./lib", 6 | "declaration": true, 7 | "allowJs": true, 8 | "strict": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src/**/*"], 12 | "exclude": ["node_modules", "**/*.spec.ts"] 13 | } 14 | --------------------------------------------------------------------------------