├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── codeql-analysis.yml │ └── gh-pages.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jsdoc ├── config.json ├── layout.tmpl └── static │ └── styles │ └── overrides.css ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── QuickScore.js ├── config.js ├── index.d.ts ├── index.js ├── quick-score.js └── range.js ├── test ├── QuickScore.test.js ├── index.test.js ├── quick-score.test.js ├── range.test.js ├── setup.js ├── tabs.js ├── transform-string.test.js └── utils.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.json] 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 2015, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "accessor-pairs": "error", 13 | "array-bracket-newline": "error", 14 | "array-bracket-spacing": "error", 15 | "array-callback-return": "error", 16 | "array-element-newline": [ 17 | "error", 18 | "consistent" 19 | ], 20 | "arrow-body-style": "error", 21 | "arrow-parens": ["error", "as-needed"], 22 | "arrow-spacing": "error", 23 | "block-scoped-var": "error", 24 | "block-spacing": "error", 25 | "brace-style": "off", 26 | "callback-return": "error", 27 | "camelcase": "error", 28 | "capitalized-comments": [ 29 | "off", 30 | "never" 31 | ], 32 | "class-methods-use-this": "off", 33 | "comma-dangle": "error", 34 | "comma-spacing": [ 35 | "error", 36 | { 37 | "after": true, 38 | "before": false 39 | } 40 | ], 41 | "comma-style": "error", 42 | "complexity": "off", 43 | "computed-property-spacing": "error", 44 | "consistent-return": "error", 45 | "consistent-this": "error", 46 | "curly": "error", 47 | "default-case": "error", 48 | "dot-location": "error", 49 | "dot-notation": "error", 50 | "eol-last": "error", 51 | "eqeqeq": "off", 52 | "func-call-spacing": "error", 53 | "func-name-matching": "error", 54 | "func-names": "off", 55 | "func-style": [ 56 | "error", 57 | "declaration" 58 | ], 59 | "function-paren-newline": "off", 60 | "generator-star-spacing": "error", 61 | "global-require": "error", 62 | "guard-for-in": "error", 63 | "handle-callback-err": "error", 64 | "id-blacklist": "error", 65 | "id-length": "off", 66 | "id-match": "error", 67 | "implicit-arrow-linebreak": "error", 68 | "indent": "off", 69 | "indent-legacy": "off", 70 | "init-declarations": "error", 71 | "jsx-quotes": "error", 72 | "key-spacing": "error", 73 | "keyword-spacing": [ 74 | "error", 75 | { 76 | "after": true, 77 | "before": true 78 | } 79 | ], 80 | "line-comment-position": "error", 81 | "linebreak-style": "off", 82 | "lines-around-comment": ["error", 83 | { "beforeBlockComment": false } 84 | ], 85 | "lines-around-directive": "error", 86 | "lines-between-class-members": [ 87 | "error", 88 | "always" 89 | ], 90 | "max-classes-per-file": "off", 91 | "max-depth": "off", 92 | "max-len": "off", 93 | "max-lines": ["error", { "skipComments": true, "skipBlankLines": true }], 94 | "max-lines-per-function": "off", 95 | "max-nested-callbacks": "error", 96 | "max-params": "off", 97 | "max-statements": "off", 98 | "max-statements-per-line": "error", 99 | "multiline-comment-style": [ 100 | "error", 101 | "separate-lines" 102 | ], 103 | "multiline-ternary": "off", 104 | "new-cap": "error", 105 | "new-parens": "error", 106 | "newline-after-var": [ 107 | "error", 108 | "always" 109 | ], 110 | "newline-before-return": "error", 111 | "newline-per-chained-call": "error", 112 | "no-alert": "error", 113 | "no-array-constructor": "error", 114 | "no-async-promise-executor": "error", 115 | "no-await-in-loop": "error", 116 | "no-bitwise": "error", 117 | "no-buffer-constructor": "error", 118 | "no-caller": "error", 119 | "no-catch-shadow": "error", 120 | "no-confusing-arrow": [ 121 | "error", 122 | { 123 | "allowParens": true 124 | } 125 | ], 126 | "no-continue": "off", 127 | "no-div-regex": "error", 128 | "no-duplicate-imports": [ 129 | "error", 130 | { 131 | "includeExports": false 132 | } 133 | ], 134 | "no-else-return": "off", 135 | "no-empty-function": "error", 136 | "no-eq-null": "error", 137 | "no-eval": "error", 138 | "no-extend-native": "error", 139 | "no-extra-bind": "error", 140 | "no-extra-label": "error", 141 | "no-extra-parens": "off", 142 | "no-floating-decimal": "off", 143 | "no-global-assign": "error", 144 | "no-implicit-coercion": "error", 145 | "no-implicit-globals": "error", 146 | "no-implied-eval": "error", 147 | "no-inline-comments": "error", 148 | "no-invalid-this": "error", 149 | "no-iterator": "error", 150 | "no-label-var": "error", 151 | "no-labels": "error", 152 | "no-lone-blocks": "error", 153 | "no-lonely-if": "error", 154 | "no-loop-func": "error", 155 | "no-magic-numbers": "off", 156 | "no-misleading-character-class": "error", 157 | "no-mixed-operators": [ 158 | "error", 159 | { 160 | "allowSamePrecedence": true 161 | } 162 | ], 163 | "no-mixed-requires": "error", 164 | "no-multi-assign": "error", 165 | "no-multi-spaces": "error", 166 | "no-multi-str": "error", 167 | "no-multiple-empty-lines": "error", 168 | "no-native-reassign": "error", 169 | "no-negated-condition": "off", 170 | "no-negated-in-lhs": "error", 171 | "no-nested-ternary": "error", 172 | "no-new": "error", 173 | "no-new-func": "error", 174 | "no-new-object": "error", 175 | "no-new-require": "error", 176 | "no-new-wrappers": "error", 177 | "no-octal-escape": "error", 178 | "no-param-reassign": "error", 179 | "no-path-concat": "error", 180 | "no-plusplus": "off", 181 | "no-process-env": "error", 182 | "no-process-exit": "error", 183 | "no-proto": "error", 184 | "no-prototype-builtins": "error", 185 | "no-restricted-globals": "error", 186 | "no-restricted-imports": "error", 187 | "no-restricted-modules": "error", 188 | "no-restricted-properties": "error", 189 | "no-restricted-syntax": "error", 190 | "no-return-assign": "error", 191 | "no-return-await": "error", 192 | "no-script-url": "error", 193 | "no-self-compare": "error", 194 | "no-sequences": "error", 195 | "no-shadow": "error", 196 | "no-shadow-restricted-names": "error", 197 | "no-spaced-func": "error", 198 | "no-sync": "error", 199 | "no-tabs": "off", 200 | "no-template-curly-in-string": "error", 201 | "no-ternary": "off", 202 | "no-throw-literal": "error", 203 | "no-trailing-spaces": "error", 204 | "no-undef-init": "error", 205 | "no-undefined": "off", 206 | "no-underscore-dangle": "error", 207 | "no-unmodified-loop-condition": "error", 208 | "no-unneeded-ternary": "error", 209 | "no-unused-expressions": "error", 210 | "no-unused-vars": ["error", {"args": "none"}], 211 | "no-use-before-define": "off", 212 | "no-useless-call": "error", 213 | "no-useless-computed-key": "error", 214 | "no-useless-concat": "error", 215 | "no-useless-constructor": "error", 216 | "no-useless-rename": "error", 217 | "no-useless-return": "error", 218 | "no-var": "error", 219 | "no-void": "error", 220 | "no-warning-comments": "error", 221 | "no-whitespace-before-property": "error", 222 | "no-with": "error", 223 | "nonblock-statement-body-position": "error", 224 | "object-curly-newline": "error", 225 | "object-curly-spacing": "off", 226 | "object-property-newline": [ 227 | "error", 228 | { "allowAllPropertiesOnSameLine": true } 229 | ], 230 | "object-shorthand": [2, "properties"], 231 | "one-var": "off", 232 | "one-var-declaration-per-line": "error", 233 | "operator-assignment": [ 234 | "error", 235 | "always" 236 | ], 237 | "operator-linebreak": [ 238 | "error", 239 | "after", 240 | { "overrides": { "?": "ignore", ":": "ignore" } } 241 | ], 242 | "padded-blocks": "off", 243 | "padding-line-between-statements": "error", 244 | "prefer-arrow-callback": "error", 245 | "prefer-const": "error", 246 | "prefer-destructuring": "error", 247 | "prefer-numeric-literals": "error", 248 | "prefer-object-spread": "off", 249 | "prefer-promise-reject-errors": "error", 250 | "prefer-reflect": "off", 251 | "prefer-rest-params": "error", 252 | "prefer-spread": "error", 253 | "prefer-template": "off", 254 | "quote-props": "off", 255 | "quotes": [ 256 | "error", 257 | "double" 258 | ], 259 | "radix": "error", 260 | "require-atomic-updates": "error", 261 | "require-await": "error", 262 | "require-jsdoc": "off", 263 | "require-unicode-regexp": "error", 264 | "rest-spread-spacing": "error", 265 | "semi": "error", 266 | "semi-spacing": [ 267 | "error", 268 | { 269 | "after": true, 270 | "before": false 271 | } 272 | ], 273 | "semi-style": [ 274 | "error", 275 | "last" 276 | ], 277 | "sort-imports": "off", 278 | "sort-keys": "off", 279 | "sort-vars": "off", 280 | "space-before-blocks": "error", 281 | "space-before-function-paren": "off", 282 | "space-in-parens": [ 283 | "error", 284 | "never" 285 | ], 286 | "space-infix-ops": "error", 287 | "space-unary-ops": "error", 288 | "spaced-comment": "error", 289 | "strict": "error", 290 | "switch-colon-spacing": "error", 291 | "symbol-description": "error", 292 | "template-curly-spacing": "error", 293 | "template-tag-spacing": "error", 294 | "unicode-bom": [ 295 | "error", 296 | "never" 297 | ], 298 | "valid-jsdoc": [2, { 299 | "prefer": { 300 | "return": "returns" 301 | }, 302 | "requireReturn": false, 303 | "requireReturnDescription": false 304 | }], 305 | "vars-on-top": "error", 306 | "wrap-regex": "error", 307 | "yield-star-spacing": "error", 308 | "yoda": [ 309 | "error", 310 | "never" 311 | ] 312 | } 313 | }; -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master, main, develop, bug/**, chore/**, feature/**, release/** ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master, main, develop, bug/**, chore/**, feature/**, release/** ] 20 | schedule: 21 | - cron: '44 14 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-20.04 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up node 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: '16' 21 | cache: 'npm' 22 | 23 | - name: Install 24 | run: npm ci 25 | 26 | - name: Build docs 27 | run: npm run build:docs 28 | 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v3 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | publish_dir: ./docs 34 | 35 | - name: Run coverage 36 | run: npm run test:coverage 37 | 38 | - name: Upload coverage to Codecov 39 | uses: codecov/codecov-action@v2 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /dist 4 | /lib 5 | /docs 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.0](https://github.com/fwextensions/quick-score/releases/tag/v0.2.0) - 2022-6-03 4 | 5 | ### Fixed 6 | 7 | - Improve TypeScript compatibility by typing the `QuickScore` array params as `readonly`. 8 | - Type the `item` key in both `ScoredString` and `ScoredObject` as a generic so `ScoredResult` is more consistent. 9 | - Update 'devDependencies' to the latest major/minor versions, including Jest 28. 10 | 11 | 12 | ## [0.1.0](https://github.com/fwextensions/quick-score/releases/tag/v0.1.0) - 2022-04-19 13 | 14 | ### Added 15 | 16 | - Add TypeScript support via `index.d.ts` declaration file, and add `types` field to `package.json`. 17 | - Update JSDocs to include type information. 18 | 19 | ### Changed 20 | 21 | - Don't concat the parameters to `setItems()` and `setKeys()` with an empty array. Call `.slice()` instead to create a copy. This means that passing a single bare string instead of an array to the `QuickScore` constructor will no longer work. 22 | - Don't supply a default value for the `sortKey` parameter in `setKeys()`. 23 | - Don't supply default values for the `string` and `query` parameters in `quickScore()`. 24 | 25 | ### Fixed 26 | 27 | - Resolve #19 and #20. 28 | - Remove empty PR trigger in `gh-pages.yml`. 29 | - Improve API docs styling. 30 | - Update `devDependencies` to the latest minor versions. 31 | 32 | 33 | ## [0.0.14](https://github.com/fwextensions/quick-score/releases/tag/v0.0.14) - 2022-02-24 34 | 35 | ### Fixed 36 | 37 | - Update `devDependencies` to the latest versions. 38 | - Add GitHub action to push docs to GitHub Pages and code coverage to Codecov. 39 | 40 | 41 | ## [0.0.13](https://github.com/fwextensions/quick-score/releases/tag/v0.0.13) - 2021-10-05 42 | 43 | ### Fixed 44 | 45 | - Update `devDependencies` to the latest minor versions. 46 | - Run npm audit fix to remove vulnerabilities. 47 | - Update .travis.yml to enable partner queue builds. 48 | - Add GitHub code analysis workflow. 49 | 50 | 51 | ## [0.0.12](https://github.com/fwextensions/quick-score/releases/tag/v0.0.12) - 2021-04-24 52 | 53 | ### Fixed 54 | 55 | - Limit the number of loops inside the `quickScore()` function so that long, nearly-matching queries don't take too long before returning a 0 score. Added `config.maxIterations` to control the number of loops. 56 | - Update `devDependencies` to latest packages. 57 | 58 | 59 | ## [0.0.11](https://github.com/fwextensions/quick-score/releases/tag/v0.0.11) - 2021-03-26 60 | 61 | ### Added 62 | 63 | - Passing an empty array in the `keys` parameter will cause all of the keys on an item to be cached and searched, without having to specify each one. 64 | - Paths to nested keys in the `keys` array can be specified as arrays of strings, instead of a dot-delimited path in a single string. Wrapping a single string in an array will cause any dots it contains to not be treated as a path. 65 | - A new `sortKey` option can be used to specify on which key to sort identically-scored items, if a key other than the first one in `keys` is desired. 66 | - A new `scoreValue` field is returned in the results from `search()`, which provides the string pointed to be `scoreKey`. This makes it easier to access the string when it's nested. 67 | 68 | 69 | ## [0.0.10](https://github.com/fwextensions/quick-score/releases/tag/v0.0.10) - 2021-01-02 70 | 71 | ### Added 72 | 73 | - A new `transformString` option to the `QuickScore` constructor can be used to ignore diacritics and accents when searching. 74 | 75 | 76 | ### Fixed 77 | 78 | - Update `devDependencies` to latest packages, fixing a vulnerability in jest. 79 | 80 | 81 | ## [0.0.9](https://github.com/fwextensions/quick-score/releases/tag/v0.0.9) - 2020-07-25 82 | 83 | ### Fixed 84 | 85 | - Update `devDependencies` to latest packages. 86 | 87 | 88 | ## [0.0.8](https://github.com/fwextensions/quick-score/releases/tag/v0.0.8) - 2020-05-07 89 | 90 | ### Fixed 91 | 92 | - Use the correct unpkg.com CDN URL in the readme. 93 | - Highlight needing to access the methods through a global when loading the library via a ` 32 | 35 | ``` 36 | 37 | 38 | ## Usage 39 | 40 | ### Calling `quickScore()` directly 41 | 42 | You can import the [`quickScore()`](https://fwextensions.github.io/quick-score/global.html#quickScore) function from the ES6 module: 43 | 44 | ```js 45 | import {quickScore} from "quick-score"; 46 | ``` 47 | 48 | Or from a property of the CommonJS module: 49 | 50 | ```js 51 | const quickScore = require("quick-score").quickScore; 52 | ``` 53 | 54 | Then call `quickScore()` with a `string` and a `query` to score against that string. It will return a floating point score between `0` and `1`. A higher score means that string is a better match for the query. A `1` means the query is the highest match for the string, though the two strings may still differ in case and whitespace characters. 55 | 56 | ```js 57 | quickScore("thought", "gh"); // 0.4142857142857143 58 | quickScore("GitHub", "gh"); // 0.9166666666666666 59 | ``` 60 | 61 | Matching `gh` against `GitHub` returns a higher score than `thought`, because it matches the capital letters in `GitHub`, which are weighted more highly. 62 | 63 | 64 | ### Sorting lists of strings with a `QuickScore` instance 65 | 66 | A typical use-case for string scoring is auto-completion, where you want the user to get to the desired result by typing as few characters as possible. Instead of calling `quickScore()` directly for every item in a list and then sorting it based on the score, it's simpler to use an instance of the [`QuickScore`](https://fwextensions.github.io/quick-score/QuickScore.html) class: 67 | 68 | ```js 69 | import {QuickScore} from "quick-score"; 70 | 71 | const qs = new QuickScore(["thought", "giraffe", "GitHub", "hello, Garth"]); 72 | const results = qs.search("gh"); 73 | 74 | // results => 75 | [ 76 | { 77 | "item": "GitHub", 78 | "score": 0.9166666666666666, 79 | "matches": [[0, 1], [3, 4]] 80 | }, 81 | { 82 | "item": "hello, Garth", 83 | "score": 0.6263888888888888, 84 | "matches": [[7, 8], [11, 12]] 85 | }, 86 | // ... 87 | ] 88 | ``` 89 | 90 | The `results` array in this example is a list of [ScoredString](https://fwextensions.github.io/quick-score/global.html#ScoredString) objects that represent the results of matching the query against each string that was passed to the constructor. It's sorted high to low on each item's score. Strings with identical scores are sorted alphabetically and case-insensitively. In the simple case of scoring bare strings, each `ScoredString` item has three properties: 91 | 92 | * `item`: the string that was scored 93 | * `score`: the floating point score of the string for the current query 94 | * `matches`: an array of arrays that specify the character ranges where the query matched the string 95 | 96 | This array could then be used to render a list of matching results as the user types a query. 97 | 98 | 99 | ### Sorting lists of objects 100 | 101 | Typically, you'll be sorting items more complex than a bare string. To tell QuickScore which of an object's keys to score a query against, pass an array of key names or dot-delimited paths as the second parameter to the `QuickScore()` constructor: 102 | 103 | ```js 104 | const bookmarks = [ 105 | { 106 | "title": "lodash documentation", 107 | "url": "https://lodash.com/docs" 108 | }, 109 | { 110 | "title": "Supplying Images - Google Chrome", 111 | "url": "developer.chrome.com/webstore/images" 112 | }, 113 | // ... 114 | ]; 115 | const qs = new QuickScore(bookmarks, ["title", "url"]); 116 | const results = qs.search("devel"); 117 | 118 | // results => 119 | [ 120 | { 121 | "item": { 122 | "title": "Supplying Images - Google Chrome", 123 | "url": "developer.chrome.com/webstore/images" 124 | }, 125 | "score": 0.9138888888888891, 126 | "scoreKey": "url", 127 | "scores": { 128 | "title": 0, 129 | "url": 0.9138888888888891 130 | }, 131 | "matches": { 132 | "title": [], 133 | "url": [[0, 5]] 134 | } 135 | }, 136 | // ... 137 | ] 138 | ``` 139 | 140 | When matching against objects, each item in the results array is a [ScoredObject](https://fwextensions.github.io/quick-score/global.html#ScoredObject), with a few additional properties : 141 | 142 | * `item`: the object that was scored 143 | * `score`: the highest score from among the individual key scores 144 | * `scoreKey`: the name of the key with the highest score, which will be an empty string if they're all zero 145 | * `scoreValue`: the value of the key with the highest score, which makes it easier to access if it's a nested string 146 | * `scores`: a hash of the individual scores for each key 147 | * `matches`: a hash of arrays that specify the character ranges of the query match for each key 148 | 149 | When two items have the same score, they're sorted alphabetically and case-insensitively on the key specified by the `sortKey` option, which defaults to the first item in the keys array. In the example above, that would be `title`. 150 | 151 | Each `ScoredObject` item also has a `_` property, which caches transformed versions of the item's strings, and might contain additional internal metadata in the future. It can be ignored. 152 | 153 | 154 | ### TypeScript support 155 | 156 | Although the QuickScore codebase is currently written in JavaScript, the package comes with full TypeScript typings. The QuickScore class takes a generic type parameter based on the type of objects in the `items` array passed to the constructor. That way, you can access `.item` on the `ScoredObject` result and get back an object of the same type that you passed in. 157 | 158 | 159 | ### Ignoring diacritics and accents when scoring 160 | 161 | If the strings you're matching against contain diacritics on some of the letters, like `à` or `ç`, you may want to count a match even when the query string contains the unaccented forms of those letters. The QuickScore library doesn't contain support for this by default, since it's only needed with certain strings and the code to remove accents would triple its size. But it's easy to combine QuickScore with other libraries to ignore diacritics. 162 | 163 | One example is the [latinize](https://github.com/dundalek/latinize) [npm package](https://www.npmjs.com/package/latinize), which will strip accents from a string and can be used in a `transformString()` function that's passed as an option to the [QuickScore constructor](https://fwextensions.github.io/quick-score/QuickScore.html#QuickScore). This function takes a `string` parameter and returns a transformed version of that string: 164 | 165 | ```js 166 | // including latinize.js on the page creates a global latinize() function 167 | import {QuickScore} from "quick-score"; 168 | 169 | const items = ["Café", "Cafeteria"]; 170 | const qs = new QuickScore(items, { transformString: s => latinize(s).toLowerCase() }); 171 | const results = qs.search("cafe"); 172 | 173 | // results => 174 | [ 175 | { 176 | "item": "Café", 177 | "score": 1, 178 | "matches": [[0, 4]], 179 | "_": "cafe" 180 | }, 181 | // ... 182 | ] 183 | ``` 184 | 185 | `transformString()` will be called on each of the searchable keys in the `items` array as well as on the `query` parameter to the `search()` method. The default function calls `toLocaleLowerCase()` on each string, for a case-insensitive search. In the example above, the basic `toLowerCase()` call is sufficient, since `latinize()` will have already stripped any accents. 186 | 187 | 188 | ### Highlighting matched letters 189 | 190 | Many search interfaces highlight the letters in each item that match what the user has typed. The `matches` property of each item in the results array contains information that can be used to highlight those matching letters. 191 | 192 | The functional component below is an example of how an item could be highlighted using React. It surrounds each sequence of matching letters in a `` tag and then returns the full string in a ``. You could then style the `` tag to be bold or a different color to highlight the matches. (Something similar could be done by concatenating plain strings of HTML tags, though you'll need to be careful to escape the substrings.) 193 | 194 | ```jsx 195 | function MatchedString({ string, matches }) { 196 | const substrings = []; 197 | let previousEnd = 0; 198 | 199 | for (let [start, end] of matches) { 200 | const prefix = string.substring(previousEnd, start); 201 | const match = {string.substring(start, end)}; 202 | 203 | substrings.push(prefix, match); 204 | previousEnd = end; 205 | } 206 | 207 | substrings.push(string.substring(previousEnd)); 208 | 209 | return {React.Children.toArray(substrings)}; 210 | } 211 | ``` 212 | 213 | The [QuickScore demo](https://fwextensions.github.io/quick-score-demo/) uses this approach to highlight the query matches, via the [MatchedString](https://github.com/fwextensions/quick-score-demo/blob/master/src/js/MatchedString.js) component. 214 | 215 | 216 | ## API 217 | 218 | See the [API docs](https://fwextensions.github.io/quick-score/) for a full description of the [QuickScore class](https://fwextensions.github.io/quick-score/QuickScore.html) and the [quickScore function](https://fwextensions.github.io/quick-score/global.html#quickScore). 219 | 220 | 221 | ## License 222 | 223 | [MIT](./LICENSE) © [John Dunning](https://github.com/fwextensions) 224 | 225 | 226 | [build-badge]: https://github.com/fwextensions/quick-score/actions/workflows/gh-pages.yml/badge.svg?style=flat-square 227 | [build]: https://github.com/fwextensions/quick-score/actions/workflows/gh-pages.yml 228 | [coverage-badge]: https://img.shields.io/codecov/c/github/fwextensions/quick-score.svg?style=flat-square 229 | [coverage]: https://codecov.io/gh/fwextensions/quick-score 230 | [dependencies-badge]: https://img.shields.io/hackage-deps/v/quick-score?style=flat-square 231 | [dependencies]: https://www.npmjs.com/package/quick-score 232 | [license-badge]: https://img.shields.io/npm/l/quick-score.svg?style=flat-square 233 | [license]: https://github.com/fwextensions/quick-score/blob/master/LICENSE 234 | [size-badge]: https://img.shields.io/bundlephobia/minzip/quick-score.svg?style=flat-square 235 | [size]: https://www.npmjs.com/package/quick-score 236 | [package-badge]: https://img.shields.io/npm/v/quick-score.svg?style=flat-square 237 | [package]: https://www.npmjs.com/package/quick-score 238 | -------------------------------------------------------------------------------- /jsdoc/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["plugins/markdown"], 3 | "source": { 4 | "include": "src" 5 | }, 6 | "templates": { 7 | "default": { 8 | "layoutFile": "jsdoc/layout.tmpl", 9 | "staticFiles": { 10 | "include": [ 11 | "jsdoc/static", 12 | "LICENSE" 13 | ] 14 | } 15 | } 16 | }, 17 | "opts": { 18 | "destination": "docs", 19 | "readme": "README.md" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /jsdoc/layout.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | JSDoc: <?js= title ?> 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |

21 | 22 | 23 |
24 | 25 | 28 | 29 |
30 | 31 |
32 | Documentation generated by JSDoc on 33 |
34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /jsdoc/static/styles/overrides.css: -------------------------------------------------------------------------------- 1 | h1, h2, h3, h4, h5 { 2 | font-weight: bold; 3 | letter-spacing: normal; 4 | } 5 | 6 | h1 { 7 | font-size: 3rem; 8 | } 9 | 10 | h2 { 11 | font-size: 2rem; 12 | } 13 | 14 | h3, h3.subsection-title { 15 | font-size: 1.75rem; 16 | letter-spacing: normal; 17 | } 18 | 19 | h4 { 20 | font-size: 1.5rem; 21 | } 22 | 23 | h4.name { 24 | font-size: 1.65rem; 25 | letter-spacing: -0.03rem; 26 | margin-bottom: .5rem; 27 | } 28 | 29 | h5, .container-overview .subsection-title { 30 | font-size: 1.15rem; 31 | color: #777 ; 32 | } 33 | 34 | h6 { 35 | font-size: 1rem; 36 | font-weight: normal; 37 | } 38 | 39 | ul { 40 | margin-block-start: .5rem; 41 | } 42 | 43 | .params code, .description code, .param-desc code { 44 | font-weight: bold; 45 | } 46 | 47 | .prettyprint { 48 | tab-size: 4; 49 | } 50 | 51 | .prettyprint.linenums li { 52 | line-height: 1.4em; 53 | } 54 | 55 | .prettyprint code { 56 | padding: 0 12px; 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quick-score", 3 | "version": "0.2.0", 4 | "description": "A JavaScript string-scoring and fuzzy-matching library based on the Quicksilver algorithm, designed for smart auto-complete.", 5 | "keywords": [ 6 | "string", 7 | "score", 8 | "sort", 9 | "search", 10 | "fuzzy", 11 | "filter", 12 | "quicksilver", 13 | "autocomplete", 14 | "auto-complete", 15 | "filter list", 16 | "intuitive sort", 17 | "smart sort" 18 | ], 19 | "license": "MIT", 20 | "author": "John Dunning (https://github.com/fwextensions)", 21 | "repository": "github:fwextensions/quick-score", 22 | "homepage": "https://fwextensions.github.io/quick-score-demo", 23 | "bugs": "https://github.com/fwextensions/quick-score/issues", 24 | "main": "lib/index.js", 25 | "module": "lib/index.esm.js", 26 | "types": "lib/index.d.ts", 27 | "files": [ 28 | "dist", 29 | "lib" 30 | ], 31 | "scripts": { 32 | "prebuild": "npm run test:coverage", 33 | "build": "npm run build:lib && npm run build:docs", 34 | "build:lib": "rimraf dist lib && rollup -c", 35 | "build:dts": "rimraf dist-dts && tsc", 36 | "build:docs": "rimraf docs && jsdoc -c jsdoc/config.json", 37 | "prepare": "npm run build:lib", 38 | "pretest": "eslint src", 39 | "test": "jest", 40 | "test:watch": "npm test -- --watch", 41 | "test:coverage": "npm test -- --coverage" 42 | }, 43 | "babel": { 44 | "env": { 45 | "test": { 46 | "presets": [ 47 | "@babel/preset-env" 48 | ] 49 | } 50 | } 51 | }, 52 | "jest": { 53 | "testEnvironment": "node", 54 | "collectCoverageFrom": [ 55 | "src/*.js", 56 | "!src/index.js" 57 | ], 58 | "setupFilesAfterEnv": [ 59 | "./test/setup.js" 60 | ] 61 | }, 62 | "devDependencies": { 63 | "@babel/core": "^7.18.2", 64 | "@babel/preset-env": "^7.18.2", 65 | "eslint": "^8.16.0", 66 | "jest": "^28.1.0", 67 | "jsdoc": "^3.6.10", 68 | "rimraf": "^3.0.2", 69 | "rollup": "^2.75.5", 70 | "rollup-plugin-babel": "^4.4.0", 71 | "rollup-plugin-babel-minify": "^10.0.0", 72 | "rollup-plugin-dts": "^4.2.2", 73 | "rollup-plugin-node-resolve": "^5.2.0", 74 | "typescript": "^4.7.3" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import minify from "rollup-plugin-babel-minify"; 3 | import dts from "rollup-plugin-dts"; 4 | 5 | 6 | const Input = "src/index.js"; 7 | const OutputESM = (dir, filename, minified) => ({ 8 | file: `${dir}/${filename ? filename : "index"}.esm${minified ? ".min" : ""}.js`, 9 | format: "esm" 10 | }); 11 | const OutputUMD = (dir, filename, minified) => ({ 12 | file: `${dir}/${filename ? filename : "quick-score"}${minified ? ".min" : ""}.js`, 13 | format: "umd", 14 | exports: "named", 15 | name: "quickScore" 16 | }); 17 | const BabelConfig = { 18 | exclude: "**/node_modules/**", 19 | // tell babel to not transform modules, so that rollup can do it 20 | presets: [ 21 | ["@babel/preset-env", { modules: false }] 22 | ] 23 | }; 24 | 25 | 26 | export default [ 27 | { 28 | input: Input, 29 | output: OutputESM("lib") 30 | }, 31 | { 32 | input: Input, 33 | output: OutputUMD("lib", "index") 34 | }, 35 | { 36 | input: Input, 37 | output: OutputUMD("dist"), 38 | plugins: [ 39 | babel(BabelConfig) 40 | ] 41 | }, 42 | { 43 | input: Input, 44 | output: OutputUMD("dist", "", true), 45 | plugins: [ 46 | babel(BabelConfig), 47 | minify({ 48 | comments: false 49 | }) 50 | ] 51 | }, 52 | { 53 | input: Input, 54 | output: OutputESM("dist", "quick-score", true), 55 | plugins: [ 56 | babel(BabelConfig), 57 | minify({ 58 | comments: false 59 | }) 60 | ] 61 | }, 62 | { 63 | input: "./src/index.d.ts", 64 | output: [ 65 | { file: "dist/index.d.ts", format: "es" }, 66 | { file: "lib/index.d.ts", format: "es" } 67 | ], 68 | plugins: [dts()] 69 | } 70 | ]; 71 | -------------------------------------------------------------------------------- /src/QuickScore.js: -------------------------------------------------------------------------------- 1 | import {quickScore} from "./quick-score"; 2 | 3 | 4 | /** 5 | * A class for scoring and sorting a list of items against a query string. Each 6 | * item receives a floating point score between `0` and `1`. 7 | */ 8 | export class QuickScore { 9 | /** 10 | * @memberOf QuickScore.prototype 11 | * @member {Array} items The array of items to search, which 12 | * should only be modified via the [setItems()]{@link QuickScore#setItems} 13 | * method. 14 | * @readonly 15 | */ 16 | 17 | /** 18 | * @memberOf QuickScore.prototype 19 | * @member {Array} keys The keys to search on each item, which 20 | * should only be modified via the [setItems()]{@link QuickScore#setKeys} 21 | * method. 22 | * @readonly 23 | */ 24 | 25 | /** 26 | * @param {Array} [items] The list of items to score. If 27 | * the list is not a flat array of strings, a `keys` array must be supplied 28 | * via the second parameter. QuickScore makes a shallow copy of the `items` 29 | * array, so changes to it won't have any affect, but changes to the objects 30 | * referenced by the array need to be passed to the instance by a call to 31 | * its [setItems()]{@link QuickScore#setItems} method. 32 | * 33 | * @param {Array|Options} [options] If the `items` parameter 34 | * is an array of flat strings, the `options` parameter can be left out. If 35 | * it is a list of objects containing keys that should be scored, the 36 | * `options` parameter must either be an array of key names or an object 37 | * containing a `keys` property. 38 | * 39 | * @param {Array} [options.keys] In the simplest case, an array of 40 | * key names to score on the objects in the `items` array. 41 | * 42 | * The key names can point to a nested key by passing either a dot-delimited 43 | * string or an array of sub-keys that specify the path to the value. So a 44 | * key `name` of `"foo.bar"` would evaluate to `"baz"` given an object like 45 | * `{ foo: { bar: "baz" } }`. Alternatively, that path could be passed as 46 | * an array, like `["foo", "bar"]`. In either case, if this sub-key's match 47 | * produces the highest score for an item in the search results, its 48 | * `scoreKey` name will be `"foo.bar"`. 49 | * 50 | * If your items have keys that contain periods, e.g., `"first.name"`, but 51 | * you don't want these names to be treated as paths to nested keys, simply 52 | * wrap the name in an array, like `{ keys: ["ssn", ["first.name"], 53 | * ["last.name"]] }`. 54 | * 55 | * Instead of a string or string array, an item in `keys` can also be passed 56 | * as a `{name, scorer}` object, which lets you specify a different scoring 57 | * function for each key. The scoring function should behave as described 58 | * next. 59 | * 60 | * @param {string} [options.sortKey=options.keys[0]] An optional key name 61 | * that will be used to sort items with identical scores. Defaults to the 62 | * name of the first item in the `keys` parameter. If `sortKey` points to 63 | * a nested key, use a dot-delimited string instead of an array to specify 64 | * the path. 65 | * 66 | * @param {number} [options.minimumScore=0] An optional value that 67 | * specifies the minimum score an item must have to appear in the results 68 | * returned from [search()]{@link QuickScore#search}. Defaults to `0`, 69 | * so items that don't match the full `query` will not be returned. This 70 | * value is ignored if the `query` is empty or undefined, in which case all 71 | * items are returned, sorted alphabetically and case-insensitively on the 72 | * `sortKey`, if any. 73 | * 74 | * @param {TransformStringFunction} [options.transformString] An optional 75 | * function that takes a `string` parameter and returns a transformed 76 | * version of that string. This function will be called on each of the 77 | * searchable keys in the `items` array as well as on the `query` 78 | * parameter to the `search()` method. The default function calls 79 | * `toLocaleLowerCase()` on each string, for a case-insensitive search. The 80 | * result of this function is cached for each searchable key on each item. 81 | * 82 | * You can pass a function here to do other kinds of preprocessing, such as 83 | * removing diacritics from all the strings or converting Chinese characters 84 | * to pinyin. For example, you could use the 85 | * [`latinize`](https://www.npmjs.com/package/latinize) npm package to 86 | * convert characters with diacritics to the base character so that your 87 | * users can type an unaccented character in the query while still matching 88 | * items that have accents or diacritics. Pass in an `options` object like 89 | * this to use a custom `transformString()` function: 90 | * `{ transformString: s => latinize(s.toLocaleLowerCase()) }` 91 | * 92 | * @param {ScorerFunction} [options.scorer] An optional function that takes 93 | * `string` and `query` parameters and returns a floating point number 94 | * between 0 and 1 that represents how well the `query` matches the 95 | * `string`. It defaults to the [quickScore()]{@link quickScore} function 96 | * in this library. 97 | * 98 | * If the function gets a third `matches` parameter, it should fill the 99 | * passed-in array with indexes corresponding to where the query 100 | * matches the string, as described in the [search()]{@link QuickScore#search} 101 | * method. 102 | * 103 | * @param {Config} [options.config] An optional object that is passed to 104 | * the scorer function to further customize its behavior. If the 105 | * `scorer` function has a `createConfig()` method on it, the `QuickScore` 106 | * instance will call that with the `config` value and store the result. 107 | * This can be used to extend the `config` parameter with default values. 108 | */ 109 | constructor( 110 | items = [], 111 | options = {}) 112 | { 113 | const { 114 | scorer = quickScore, 115 | transformString = toLocaleLowerCase, 116 | keys = [], 117 | sortKey = "", 118 | minimumScore = 0, 119 | config 120 | } = Array.isArray(options) 121 | ? { keys: options } 122 | : options; 123 | 124 | this.scorer = scorer; 125 | this.minimumScore = minimumScore; 126 | this.config = config; 127 | this.transformStringFunc = transformString; 128 | 129 | if (typeof scorer.createConfig === "function") { 130 | // let the scorer fill out the config with default values 131 | this.config = scorer.createConfig(config); 132 | } 133 | 134 | this.setKeys(keys, sortKey); 135 | this.setItems(items); 136 | 137 | // the scoring function needs access to this.sortKey 138 | this.compareScoredStrings = this.compareScoredStrings.bind(this); 139 | } 140 | 141 | 142 | /** 143 | * Scores the instance's items against the `query` and sorts them from 144 | * highest to lowest. 145 | * 146 | * @param {string} query The string to score each item against. The 147 | * instance's `transformString()` function is called on this string before 148 | * it's matched against each item. 149 | * 150 | * @returns {Array} When the instance's `items` 151 | * are flat strings, an array of [`ScoredString`]{@link ScoredString} 152 | * objects containing the following properties is returned: 153 | * 154 | * - `item`: the string that was scored 155 | * - `score`: the floating point score of the string for the current query 156 | * - `matches`: an array of arrays that specify the character ranges 157 | * where the query matched the string 158 | * 159 | * When the `items` are objects, an array of [`ScoredObject`]{@link ScoredObject} 160 | * results is returned: 161 | * 162 | * - `item`: the object that was scored 163 | * - `score`: the highest score from among the individual key scores 164 | * - `scoreKey`: the name of the key with the highest score, which will be 165 | * an empty string if they're all zero 166 | * - `scoreValue`: the value of the key with the highest score, which makes 167 | * it easier to access if it's a nested string 168 | * - `scores`: a hash of the individual scores for each key 169 | * - `matches`: a hash of arrays that specify the character ranges of the 170 | * query match for each key 171 | * 172 | * The results array is sorted high to low on each item's score. Items with 173 | * identical scores are sorted alphabetically and case-insensitively on the 174 | * `sortKey` option. Items with scores that are <= the `minimumScore` option 175 | * (defaults to `0`) are not returned, unless the `query` is falsy, in which 176 | * case all of the items are returned, sorted alphabetically. 177 | * 178 | * The start and end indices in each [`RangeTuple`]{@link RangeTuple} in the 179 | * `matches` array can be used as parameters to the `substring()` method to 180 | * extract the characters from each string that match the query. This can 181 | * then be used to format the matching characters with a different color or 182 | * style. 183 | * 184 | * Each `ScoredObject` item also has a `_` property, which caches transformed 185 | * versions of the item's strings, and might contain additional internal 186 | * metadata in the future. It can be ignored. 187 | */ 188 | search( 189 | query) 190 | { 191 | const results = []; 192 | const {items, transformedItems, keys: sharedKeys, config} = this; 193 | // if the query is empty, we want to return all items, so make the 194 | // minimum score less than 0 195 | const minScore = query ? this.minimumScore : -1; 196 | const transformedQuery = this.transformString(query); 197 | const itemCount = items.length; 198 | const sharedKeyCount = sharedKeys.length; 199 | 200 | if (typeof items[0] === "string") { 201 | // items is an array of strings 202 | for (let i = 0; i < itemCount; i++) { 203 | const item = items[i]; 204 | const transformedItem = transformedItems[i]; 205 | const matches = []; 206 | const score = this.scorer(item, query, matches, transformedItem, 207 | transformedQuery, config); 208 | 209 | if (score > minScore) { 210 | results.push({ 211 | item, 212 | score, 213 | matches, 214 | _: transformedItem 215 | }); 216 | } 217 | } 218 | } else { 219 | for (let i = 0; i < itemCount; i++) { 220 | const item = items[i]; 221 | const transformedItem = transformedItems[i]; 222 | const result = { 223 | item, 224 | score: 0, 225 | scoreKey: "", 226 | scoreValue: "", 227 | scores: {}, 228 | matches: {}, 229 | _: transformedItem 230 | }; 231 | // if an empty keys array was passed into the constructor, 232 | // score all of the non-empty string keys on the object 233 | const keys = sharedKeyCount ? sharedKeys : Object.keys(transformedItem); 234 | const keyCount = keys.length; 235 | let highScore = 0; 236 | let scoreKey = ""; 237 | let scoreValue = ""; 238 | 239 | // find the highest score for each keyed string on this item 240 | for (let j = 0; j < keyCount; j++) { 241 | const key = keys[j]; 242 | // use the key as the name if it's just a string, and 243 | // default to the instance's scorer function 244 | const {name = key, scorer = this.scorer} = key; 245 | const transformedString = transformedItem[name]; 246 | 247 | // setItems() checks for non-strings and empty strings 248 | // when creating the transformed objects, so if the key 249 | // doesn't exist there, we can skip the processing 250 | // below for this key in this item 251 | if (transformedString) { 252 | const string = this.getItemString(item, key); 253 | const matches = []; 254 | const newScore = scorer(string, query, matches, 255 | transformedString, transformedQuery, config); 256 | 257 | result.scores[name] = newScore; 258 | result.matches[name] = matches; 259 | 260 | if (newScore > highScore) { 261 | highScore = newScore; 262 | scoreKey = name; 263 | scoreValue = string; 264 | } 265 | } 266 | } 267 | 268 | if (highScore > minScore) { 269 | result.score = highScore; 270 | result.scoreKey = scoreKey; 271 | result.scoreValue = scoreValue; 272 | results.push(result); 273 | } 274 | } 275 | } 276 | 277 | results.sort(this.compareScoredStrings); 278 | 279 | return results; 280 | } 281 | 282 | 283 | /** 284 | * Sets the `keys` configuration. `setItems()` must be called after 285 | * changing the keys so that the items' transformed strings get cached. 286 | * 287 | * @param {Array} keys List of keys to score, as either strings 288 | * or `{name, scorer}` objects. 289 | * 290 | * @param {string} [sortKey=keys[0]] Name of key on which to sort 291 | * identically scored items. Defaults to the first `keys` item. 292 | */ 293 | setKeys( 294 | keys, 295 | sortKey) 296 | { 297 | // create a shallow copy of the keys array so that changes to its 298 | // order outside of this instance won't affect searching 299 | this.keys = keys.slice(); 300 | this.sortKey = sortKey; 301 | 302 | if (this.keys.length) { 303 | const {scorer} = this; 304 | 305 | // associate each key with the scorer function, if it isn't already 306 | this.keys = this.keys.map(itemKey => { 307 | // items in the keys array should either be a string or 308 | // array specifying a key name, or a { name, scorer } object 309 | const key = itemKey.length 310 | ? { name: itemKey, scorer } 311 | : itemKey; 312 | 313 | if (Array.isArray(key.name)) { 314 | if (key.name.length > 1) { 315 | key.path = key.name; 316 | key.name = key.path.join("."); 317 | } else { 318 | // this path consists of just one key name, which was 319 | // probably wrapped in an array because it contains 320 | // dots but isn't intended as a key path. so don't 321 | // create a path array on this key, so that we're not 322 | // constantly calling reduce() to get this one key. 323 | [key.name] = key.name; 324 | } 325 | } else if (key.name.indexOf(".") > -1) { 326 | key.path = key.name.split("."); 327 | } 328 | 329 | return key; 330 | }); 331 | 332 | this.sortKey = this.sortKey || this.keys[0].name; 333 | } 334 | } 335 | 336 | 337 | /** 338 | * Sets the `items` array and caches a transformed copy of all the item 339 | * strings specified by the `keys` parameter to the constructor, using the 340 | * `transformString` option (which defaults to `toLocaleLowerCase()`). 341 | * 342 | * @param {Array} items List of items to score. 343 | */ 344 | setItems( 345 | items) 346 | { 347 | // create a shallow copy of the items array so that changes to its 348 | // order outside of this instance won't affect searching 349 | const itemArray = items.slice(); 350 | const itemCount = itemArray.length; 351 | const transformedItems = []; 352 | const sharedKeys = this.keys; 353 | const sharedKeyCount = sharedKeys.length; 354 | 355 | if (typeof itemArray[0] === "string") { 356 | for (let i = 0; i < itemCount; i++) { 357 | transformedItems.push(this.transformString(itemArray[i])); 358 | } 359 | } else { 360 | for (let i = 0; i < itemCount; i++) { 361 | const item = itemArray[i]; 362 | const transformedItem = {}; 363 | const keys = sharedKeyCount ? sharedKeys : Object.keys(item); 364 | const keyCount = keys.length; 365 | 366 | for (let j = 0; j < keyCount; j++) { 367 | const key = keys[j]; 368 | const string = this.getItemString(item, key); 369 | 370 | if (string && typeof string === "string") { 371 | transformedItem[key.name || key] = 372 | this.transformString(string); 373 | } 374 | } 375 | 376 | transformedItems.push(transformedItem); 377 | } 378 | } 379 | 380 | this.items = itemArray; 381 | this.transformedItems = transformedItems; 382 | } 383 | 384 | 385 | /** 386 | * Gets an item's key, possibly at a nested path. 387 | * 388 | * @private 389 | * @param {object} item An object with multiple string properties. 390 | * @param {object|string} key A key object with 391 | * the name of the string to get from `item`, or a plain string when all 392 | * keys on an item are being matched. 393 | * @returns {string} 394 | */ 395 | getItemString( 396 | item, 397 | key) 398 | { 399 | const {name, path} = key; 400 | 401 | if (path) { 402 | return path.reduce((value, prop) => value && value[prop], item); 403 | } else { 404 | // if this instance is scoring all the keys on each item, key 405 | // will just be a string, not a { name, scorer } object 406 | return item[name || key]; 407 | } 408 | } 409 | 410 | 411 | /** 412 | * Transforms a string into a canonical form for scoring. 413 | * 414 | * @private 415 | * @param {string} string The string to transform. 416 | * @returns {string} 417 | */ 418 | transformString( 419 | string) 420 | { 421 | return this.transformStringFunc(string); 422 | } 423 | 424 | 425 | /** 426 | * Compares two items based on their scores, or on their `sortKey` if the 427 | * scores are identical. 428 | * 429 | * @private 430 | * @param {object} a First item. 431 | * @param {object} b Second item. 432 | * @returns {number} 433 | */ 434 | compareScoredStrings( 435 | a, 436 | b) 437 | { 438 | // use the transformed versions of the strings for sorting 439 | const itemA = a._; 440 | const itemB = b._; 441 | const itemAString = typeof itemA === "string" 442 | ? itemA 443 | : itemA[this.sortKey]; 444 | const itemBString = typeof itemB === "string" 445 | ? itemB 446 | : itemB[this.sortKey]; 447 | 448 | if (a.score === b.score) { 449 | // sort undefineds to the end of the array, as per the ES spec 450 | if (itemAString === undefined || itemBString === undefined) { 451 | if (itemAString === undefined && itemBString === undefined) { 452 | return 0; 453 | } else if (itemAString === undefined) { 454 | return 1; 455 | } else { 456 | return -1; 457 | } 458 | } else if (itemAString === itemBString) { 459 | return 0; 460 | } else if (itemAString < itemBString) { 461 | return -1; 462 | } else { 463 | return 1; 464 | } 465 | } else { 466 | return b.score - a.score; 467 | } 468 | } 469 | } 470 | 471 | 472 | /** 473 | * Default function for transforming each string to be searched. 474 | * 475 | * @private 476 | * @param {string} string The string to transform. 477 | * @returns {string} The transformed string. 478 | */ 479 | function toLocaleLowerCase( 480 | string) 481 | { 482 | return string.toLocaleLowerCase(); 483 | } 484 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const BaseConfigDefaults = { 2 | wordSeparators: "-/\\:()<>%._=&[]+ \t\n\r", 3 | uppercaseLetters: (() => { 4 | const charCodeA = "A".charCodeAt(0); 5 | const uppercase = []; 6 | 7 | for (let i = 0; i < 26; i++) { 8 | uppercase.push(String.fromCharCode(charCodeA + i)); 9 | } 10 | 11 | return uppercase.join(""); 12 | })(), 13 | ignoredScore: 0.9, 14 | skippedScore: 0.15, 15 | emptyQueryScore: 0, 16 | // long, nearly-matching queries can generate up to 2^queryLength loops, 17 | // so support worst-case queries up to 16 characters and then give up 18 | // and return 0 for longer queries that may or may not actually match 19 | maxIterations: Math.pow(2, 16) 20 | }; 21 | const QSConfigDefaults = { 22 | longStringLength: 150, 23 | maxMatchStartPct: 0.15, 24 | minMatchDensityPct: 0.75, 25 | maxMatchDensityPct: 0.95, 26 | beginningOfStringPct: 0.1 27 | }; 28 | 29 | 30 | class Config { 31 | constructor( 32 | options) 33 | { 34 | Object.assign(this, BaseConfigDefaults, options); 35 | } 36 | 37 | 38 | useSkipReduction() 39 | { 40 | return true; 41 | } 42 | 43 | 44 | adjustRemainingScore( 45 | string, 46 | query, 47 | remainingScore, 48 | skippedSpecialChar, 49 | searchRange, 50 | remainingSearchRange, 51 | matchedRange, 52 | fullMatchedRange) 53 | { 54 | // use the original Quicksilver expression for the remainingScore 55 | return remainingScore * remainingSearchRange.length; 56 | } 57 | } 58 | 59 | 60 | class QuickScoreConfig extends Config { 61 | constructor( 62 | options) 63 | { 64 | super(Object.assign({}, QSConfigDefaults, options)); 65 | } 66 | 67 | 68 | useSkipReduction( 69 | string, 70 | query, 71 | remainingScore, 72 | searchRange, 73 | remainingSearchRange, 74 | matchedRange, 75 | fullMatchedRange) 76 | { 77 | const len = string.length; 78 | const isShortString = len <= this.longStringLength; 79 | const matchStartPercentage = fullMatchedRange.location / len; 80 | 81 | return isShortString || matchStartPercentage < this.maxMatchStartPct; 82 | } 83 | 84 | 85 | adjustRemainingScore( 86 | string, 87 | query, 88 | remainingScore, 89 | skippedSpecialChar, 90 | searchRange, 91 | remainingSearchRange, 92 | matchedRange, 93 | fullMatchedRange) 94 | { 95 | const isShortString = string.length <= this.longStringLength; 96 | const matchStartPercentage = fullMatchedRange.location / string.length; 97 | let matchRangeDiscount = 1; 98 | let matchStartDiscount = (1 - matchStartPercentage); 99 | 100 | // discount the remainingScore based on how much larger the match is 101 | // than the query, unless the match is in the first 10% of the 102 | // string, the match range isn't too sparse and the whole string is 103 | // not too long. also only discount if we didn't skip any whitespace 104 | // or capitals. 105 | if (!skippedSpecialChar) { 106 | matchRangeDiscount = query.length / fullMatchedRange.length; 107 | matchRangeDiscount = (isShortString && 108 | matchStartPercentage <= this.beginningOfStringPct && 109 | matchRangeDiscount >= this.minMatchDensityPct) ? 110 | 1 : matchRangeDiscount; 111 | matchStartDiscount = matchRangeDiscount >= this.maxMatchDensityPct ? 112 | 1 : matchStartDiscount; 113 | } 114 | 115 | // discount the scores of very long strings 116 | return remainingScore * 117 | Math.min(remainingSearchRange.length, this.longStringLength) * 118 | matchRangeDiscount * matchStartDiscount; 119 | } 120 | } 121 | 122 | 123 | export function createConfig( 124 | options) 125 | { 126 | if (options instanceof Config) { 127 | // this is a full-fledged Config instance, so we don't need to do 128 | // anything to it 129 | return options; 130 | } else { 131 | // create a complete config from this 132 | return new QuickScoreConfig(options); 133 | } 134 | } 135 | 136 | 137 | export const DefaultConfig = createConfig(); 138 | export const BaseConfig = new Config(); 139 | export const QuicksilverConfig = new Config({ 140 | // the Quicksilver algorithm returns .9 for empty queries 141 | emptyQueryScore: 0.9, 142 | adjustRemainingScore: function( 143 | string, 144 | query, 145 | remainingScore, 146 | skippedSpecialChar, 147 | searchRange, 148 | remainingSearchRange, 149 | matchedRange, 150 | fullMatchedRange) 151 | { 152 | let score = remainingScore * remainingSearchRange.length; 153 | 154 | if (!skippedSpecialChar) { 155 | // the current QuickSilver algorithm reduces the score by half 156 | // this value when no special chars are skipped, so add the half 157 | // back in to match it 158 | score += ((matchedRange.location - searchRange.location) / 2.0); 159 | } 160 | 161 | return score; 162 | } 163 | }); 164 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {function(string, string, Array?): number} ScorerFunction 3 | * A function that takes `string` and `query` parameters and returns a floating 4 | * point number between 0 and 1 that represents how well the `query` matches the 5 | * `string`. It defaults to the [quickScore()]{@link quickScore} function in 6 | * this library. 7 | * 8 | * If the function gets a third `matches` parameter, it should fill the passed-in 9 | * array with indexes corresponding to where the query matches the string, as 10 | * described in the [search()]{@link QuickScore#search} method. 11 | * 12 | * @param {string} string The string to score. 13 | * @param {string} query The query string to score the `string` parameter against. 14 | * @param {Array} [matches] If supplied, the function should push 15 | * onto `matches` a tuple with start and end indexes for each substring range 16 | * of `string` that matches `query`. 17 | * @returns {number} A number between 0 and 1 that represents how well the 18 | * `query` matches the `string`. 19 | */ 20 | export type ScorerFunction = (string: string, query: string, matches?: [number, number][]) => number; 21 | 22 | /** 23 | * @typedef {function(string): string} TransformStringFunction A function that 24 | * takes a `string` parameter and returns a transformed version of that string. 25 | * 26 | * @param {string} string The string to be transformed. 27 | * @returns {string} The string with the transform applied to it. 28 | */ 29 | export type TransformStringFunction = (string: string) => string; 30 | 31 | /** 32 | * @typedef {string|string[]} KeyName A reference to an item's key to search. 33 | * The key names can point to a nested key by passing either a dot-delimited 34 | * string or an array of sub-keys that specify the path to the value. 35 | */ 36 | export type KeyName = string | string[]; 37 | 38 | /** 39 | * @typedef {KeyName|{name: KeyName, scorer: ScorerFunction}} ItemKey A 40 | * reference to an item's key to search. This type can also include a custom 41 | * scoring function to use for the given key. 42 | * 43 | * @property {KeyName} [name] The name of a key to search. 44 | * @property {ScorerFunction} [scorer] The function that will be used to score 45 | * the named string. 46 | */ 47 | export type ItemKey = (KeyName | { name: KeyName, scorer: ScorerFunction }); 48 | 49 | /** 50 | * @typedef {object} Options An object specifying various options that can 51 | * customize QuickScore's scoring behavior. 52 | * 53 | * @property {Array} [keys] An array that specifies which keys to 54 | * search. 55 | * @property {string} [sortKey] The name of the key that will be used to sort 56 | * items with identical scores. 57 | * @property {number} [minimumScore] The minimum score an item must have to 58 | * appear in the results returned from `search()`. 59 | * @property {TransformStringFunction} [transformString] A function that takes 60 | * a `string` parameter and returns a transformed version of that string. 61 | * @property {ScorerFunction} [scorer] A function that takes `string` and 62 | * `query` parameters and returns a floating point number between 0 and 1 that 63 | * represents how well the `query` matches the `string`. 64 | * @property {Config} [config] An object that is passed to the scorer function 65 | * to further customize its behavior. 66 | */ 67 | export interface Options { 68 | keys?: ItemKey[], 69 | sortKey?: string, 70 | minimumScore?: number, 71 | transformString?: TransformStringFunction, 72 | scorer?: ScorerFunction, 73 | config?: Config 74 | } 75 | 76 | /** 77 | * @typedef {object} ScoredString An object representing the results of scoring 78 | * an `items` array that contains strings. 79 | * 80 | * @property {string} item The string that was scored. 81 | * @property {number} score The floating point score of the string for the 82 | * current query. 83 | * @property {Array} matches An array of tuples that specify the 84 | * character ranges where the query matched the string. 85 | */ 86 | export interface ScoredString { 87 | item: T, 88 | score: number, 89 | matches: RangeTuple[], 90 | } 91 | 92 | /** 93 | * @typedef {object} ScoredObject An object representing the results of scoring 94 | * an `items` array that contains objects. 95 | * 96 | * @property {object} item The object that was scored. 97 | * @property {number} score The highest score from among the individual key scores. 98 | * @property {string} scoreKey The name of the key with the highest score, 99 | * which will be an empty string if they're all zero. 100 | * @property {string} scoreValue The value of the key with the highest score, 101 | * which makes it easier to access if it's a nested string. 102 | * @property {object} scores A hash of the individual scores for each key. 103 | * @property {object} matches A hash of arrays that specify the character 104 | * ranges of the query match for each key. 105 | * @property {object} _ An internal cache of the transformed versions of this 106 | * item's strings and other metadata, which can be ignored. 107 | */ 108 | export interface ScoredObject { 109 | item: T, 110 | score: number, 111 | scoreKey: string, 112 | scoreValue: string, 113 | scores: { [k: string]: number }, 114 | matches: { [k: string]: RangeTuple[] }, 115 | _?: unknown 116 | } 117 | 118 | export type ScoredResult = T extends string 119 | ? ScoredString 120 | : ScoredObject; 121 | 122 | /** 123 | * A class for scoring and sorting a list of items against a query string. Each 124 | * item receives a floating point score between `0` and `1`. 125 | */ 126 | export class QuickScore { 127 | /** 128 | * @memberOf QuickScore.prototype 129 | * @member {Array} items The array of items to search, which 130 | * should only be modified via the [setItems()]{@link QuickScore#setItems} 131 | * method. 132 | * @readonly 133 | */ 134 | items: readonly T[]; 135 | 136 | /** 137 | * @memberOf QuickScore.prototype 138 | * @member {Array} keys The keys to search on each item, which 139 | * should only be modified via the [setItems()]{@link QuickScore#setKeys} 140 | * method. 141 | * @readonly 142 | */ 143 | keys: readonly ItemKey[]; 144 | 145 | /** 146 | * @param {Array} [items] The list of items to score. If 147 | * the list is not a flat array of strings, a `keys` array must be supplied 148 | * via the second parameter. The `items` array is not modified by QuickScore. 149 | * 150 | * @param {Array|Options} [options] If the `items` parameter 151 | * is an array of flat strings, the `options` parameter can be left out. If 152 | * it is a list of objects containing keys that should be scored, the 153 | * `options` parameter must either be an array of key names or an object 154 | * containing a `keys` property. 155 | * 156 | * @param {Array} [options.keys] In the simplest case, an array of 157 | * key names to score on the objects in the `items` array. 158 | * 159 | * The key names can point to a nested key by passing either a dot-delimited 160 | * string or an array of sub-keys that specify the path to the value. So a 161 | * key `name` of `"foo.bar"` would evaluate to `"baz"` given an object like 162 | * `{ foo: { bar: "baz" } }`. Alternatively, that path could be passed as 163 | * an array, like `["foo", "bar"]`. In either case, if this sub-key's match 164 | * produces the highest score for an item in the search results, its 165 | * `scoreKey` name will be `"foo.bar"`. 166 | * 167 | * If your items have keys that contain periods, e.g., `"first.name"`, but 168 | * you don't want these names to be treated as paths to nested keys, simply 169 | * wrap the name in an array, like `{ keys: ["ssn", ["first.name"], 170 | * ["last.name"]] }`. 171 | * 172 | * Instead of a string or string array, an item in `keys` can also be passed 173 | * as a `{name, scorer}` object, which lets you specify a different scoring 174 | * function for each key. The scoring function should behave as described 175 | * next. 176 | * 177 | * @param {string} [options.sortKey=options.keys[0]] An optional key name 178 | * that will be used to sort items with identical scores. Defaults to the 179 | * name of the first item in the `keys` parameter. If `sortKey` points to 180 | * a nested key, use a dot-delimited string instead of an array to specify 181 | * the path. 182 | * 183 | * @param {number} [options.minimumScore=0] An optional value that 184 | * specifies the minimum score an item must have to appear in the results 185 | * returned from [search()]{@link QuickScore#search}. Defaults to `0`, 186 | * so items that don't match the full `query` will not be returned. This 187 | * value is ignored if the `query` is empty or undefined, in which case all 188 | * items are returned, sorted alphabetically and case-insensitively on the 189 | * `sortKey`, if any. 190 | * 191 | * @param {TransformStringFunction} [options.transformString] An optional 192 | * function that takes a `string` parameter and returns a transformed 193 | * version of that string. This function will be called on each of the 194 | * searchable keys in the `items` array as well as on the `query` 195 | * parameter to the `search()` method. The default function calls 196 | * `toLocaleLowerCase()` on each string, for a case-insensitive search. The 197 | * result of this function is cached for each searchable key on each item. 198 | * 199 | * You can pass a function here to do other kinds of preprocessing, such as 200 | * removing diacritics from all the strings or converting Chinese characters 201 | * to pinyin. For example, you could use the 202 | * [`latinize`](https://www.npmjs.com/package/latinize) npm package to 203 | * convert characters with diacritics to the base character so that your 204 | * users can type an unaccented character in the query while still matching 205 | * items that have accents or diacritics. Pass in an `options` object like 206 | * this to use a custom `transformString()` function: 207 | * `{ transformString: s => latinize(s.toLocaleLowerCase()) }` 208 | * 209 | * @param {ScorerFunction} [options.scorer] An optional function that takes 210 | * `string` and `query` parameters and returns a floating point number 211 | * between 0 and 1 that represents how well the `query` matches the 212 | * `string`. It defaults to the [quickScore()]{@link quickScore} function 213 | * in this library. 214 | * 215 | * If the function gets a third `matches` parameter, it should fill the 216 | * passed-in array with indexes corresponding to where the query 217 | * matches the string, as described in the [search()]{@link QuickScore#search} 218 | * method. 219 | * 220 | * @param {Config} [options.config] An optional object that is passed to 221 | * the scorer function to further customize its behavior. If the 222 | * `scorer` function has a `createConfig()` method on it, the `QuickScore` 223 | * instance will call that with the `config` value and store the result. 224 | * This can be used to extend the `config` parameter with default values. 225 | */ 226 | constructor( 227 | items?: readonly T[], 228 | options?: readonly ItemKey[] | Options 229 | ); 230 | 231 | /** 232 | * Scores the instance's items against the `query` and sorts them from 233 | * highest to lowest. 234 | * 235 | * @param {string} query The string to score each item against. The 236 | * instance's `transformString()` function is called on this string before 237 | * it's matched against each item. 238 | * 239 | * @returns {Array>} When the instance's `items` 240 | * are flat strings, an array of `ScoredString` objects containing the 241 | * following properties is returned: 242 | * 243 | * - `item`: the string that was scored 244 | * - `score`: the floating point score of the string for the current query 245 | * - `matches`: an array of arrays that specify the character ranges 246 | * where the query matched the string 247 | * 248 | * When the `items` are objects, an array of `ScoredObject` results is 249 | * returned: 250 | * 251 | * - `item`: the object that was scored 252 | * - `score`: the highest score from among the individual key scores 253 | * - `scoreKey`: the name of the key with the highest score, which will be 254 | * an empty string if they're all zero 255 | * - `scoreValue`: the value of the key with the highest score, which makes 256 | * it easier to access if it's a nested string 257 | * - `scores`: a hash of the individual scores for each key 258 | * - `matches`: a hash of arrays that specify the character ranges of the 259 | * query match for each key 260 | * 261 | * The results array is sorted high to low on each item's score. Items with 262 | * identical scores are sorted alphabetically and case-insensitively on the 263 | * `sortKey` option. Items with scores that are <= the `minimumScore` option 264 | * (defaults to `0`) are not returned, unless the `query` is falsy, in which 265 | * case all of the items are returned, sorted alphabetically. 266 | * 267 | * The start and end indices in each `RangeTuple` in the `matches` array 268 | * can be used as parameters to the `substring()` method to extract the 269 | * characters from each string that match the query. This can then be used 270 | * to format the matching characters with a different color or style. 271 | * 272 | * Each `ScoredObject` item also has a `_` property, which caches transformed 273 | * versions of the item's strings, and might contain additional internal 274 | * metadata in the future. It can be ignored. 275 | */ 276 | search(query: string): ScoredResult[]; 277 | 278 | /** 279 | * Sets the `keys` configuration. `setItems()` must be called after 280 | * changing the keys so that the items' transformed strings get cached. 281 | * 282 | * @param {Array} keys List of keys to score, as either strings 283 | * or `{name, scorer}` objects. 284 | * 285 | * @param {string} [sortKey=keys[0]] Name of key on which to sort 286 | * identically scored items. Defaults to the first `keys` item. 287 | */ 288 | setKeys(keys: readonly ItemKey[], sortKey?: string): void; 289 | 290 | /** 291 | * Sets the `items` array and caches a transformed copy of all the item 292 | * strings specified by the `keys` parameter to the constructor, using the 293 | * `transformString` option (which defaults to `toLocaleLowerCase()`). 294 | * 295 | * @param {T[]} items List of items to score. 296 | */ 297 | setItems(items: readonly T[]): void; 298 | } 299 | 300 | 301 | /** 302 | * A class representing a half-open interval of characters. A range's `location` 303 | * property and `max()` value can be used as arguments for the `substring()` 304 | * method to extract a range of characters. 305 | */ 306 | export class Range { 307 | /** 308 | * @param {number} [location=-1] - Starting index of the range. 309 | * @param {number} [length=0] - Number of characters in the range. 310 | */ 311 | constructor(location?: number, length?: number); 312 | 313 | location: number; 314 | length: number; 315 | 316 | /** 317 | * Gets the end index of the range, which indicates the character 318 | * immediately after the last one in the range. 319 | * 320 | * @returns {number} 321 | */ /** 322 | * Sets the end index of the range, which indicates the character 323 | * immediately after the last one in the range. 324 | * 325 | * @param {number} [value] - End of the range. 326 | * 327 | * @returns {number} 328 | */ 329 | max(value?: number): number; 330 | 331 | /** 332 | * Returns whether the range contains a location >= 0. 333 | * 334 | * @returns {boolean} 335 | */ 336 | isValid(): boolean; 337 | 338 | /** 339 | * Returns a tuple of the range's start and end indexes. 340 | * 341 | * @returns {RangeTuple} 342 | */ 343 | toArray(): RangeTuple; 344 | 345 | /** 346 | * Returns a string representation of the range's open interval. 347 | * 348 | * @returns {string} 349 | */ 350 | toString(): string; 351 | } 352 | 353 | /** 354 | * @typedef {Array} RangeTuple A tuple containing a range's start and 355 | * end indexes. 356 | * 357 | * @property {number} 0 Start index. 358 | * @property {number} 1 End index. 359 | */ 360 | export type RangeTuple = [number, number]; 361 | 362 | 363 | export interface ConfigOptions { 364 | wordSeparators?: string, 365 | uppercaseLetters?: string, 366 | ignoredScore?: number, 367 | skippedScore?: number, 368 | emptyQueryScore?: number, 369 | maxIterations?: number, 370 | } 371 | 372 | export interface QSConfigOptions extends ConfigOptions { 373 | longStringLength: number, 374 | maxMatchStartPct: number, 375 | minMatchDensityPct: number, 376 | maxMatchDensityPct: number, 377 | beginningOfStringPct: number 378 | } 379 | 380 | export class Config { 381 | constructor(options: ConfigOptions); 382 | 383 | useSkipReduction( 384 | string: string, 385 | query: string, 386 | remainingScore: number, 387 | searchRange: Range, 388 | remainingSearchRange: Range, 389 | matchedRange: Range, 390 | fullMatchedRange: Range 391 | ): boolean; 392 | 393 | adjustRemainingScore( 394 | string: string, 395 | query: string, 396 | remainingScore: number, 397 | skippedSpecialChar: boolean, 398 | searchRange: Range, 399 | remainingSearchRange: Range, 400 | matchedRange: Range, 401 | fullMatchedRange: Range 402 | ): number; 403 | } 404 | 405 | export class QuickScoreConfig extends Config { } 406 | 407 | export function createConfig( 408 | options: Config | ConfigOptions | QSConfigOptions 409 | ): Config; 410 | 411 | export const DefaultConfig: QuickScoreConfig; 412 | export const BaseConfig: Config; 413 | export const QuicksilverConfig: Config; 414 | 415 | 416 | /** 417 | * Scores a string against a query. 418 | * 419 | * @param {string} string The string to score. 420 | * 421 | * @param {string} query The query string to score the `string` parameter against. 422 | * 423 | * @param {Array} [matches] If supplied, `quickScore()` will push 424 | * onto `matches` a tuple with start and end indexes for each substring range of 425 | * `string` that matches `query`. These indexes can be used to highlight the 426 | * matching characters in an auto-complete UI. 427 | * 428 | * @param {string} [transformedString] A transformed version of the string that 429 | * will be used for matching. This defaults to a lowercase version of `string`, 430 | * but it could also be used to match against a string with all the diacritics 431 | * removed, so an unaccented character in the query would match an accented one 432 | * in the string. 433 | * 434 | * @param {string} [transformedQuery] A transformed version of `query`. The 435 | * same transformation applied to `transformedString` should be applied to this 436 | * parameter, or both can be left as `undefined` for the default lowercase 437 | * transformation. 438 | * 439 | * @param {object} [config] A configuration object that can modify how the 440 | * `quickScore` algorithm behaves. 441 | * 442 | * @param {Range} [stringRange] The range of characters in `string` that should 443 | * be checked for matches against `query`. Defaults to the entire `string` 444 | * parameter. 445 | * 446 | * @returns {number} A number between 0 and 1 that represents how well the 447 | * `query` matches the `string`. 448 | */ 449 | export function quickScore( 450 | string: string, 451 | query: string, 452 | matches?: RangeTuple[], 453 | transformedString?: string, 454 | transformedQuery?: string, 455 | config?: Config, 456 | stringRange?: Range 457 | ): number; 458 | 459 | export namespace quickScore { 460 | export { createConfig }; 461 | } 462 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export {QuickScore} from "./QuickScore"; 2 | export {quickScore} from "./quick-score"; 3 | export * from "./config"; 4 | export {Range} from "./range"; 5 | 6 | /** 7 | * @typedef {Array} RangeTuple A tuple containing a range's start and 8 | * end indexes. 9 | * 10 | * @property {number} 0 Start index. 11 | * @property {number} 1 End index. 12 | */ 13 | 14 | /** 15 | * @typedef {function(string, string, Array?): number} ScorerFunction 16 | * A function that takes `string` and `query` parameters and returns a floating 17 | * point number between 0 and 1 that represents how well the `query` matches the 18 | * `string`. It defaults to the [quickScore()]{@link quickScore} function in 19 | * this library. 20 | * 21 | * If the function gets a third `matches` parameter, it should fill the passed-in 22 | * array with indexes corresponding to where the query matches the string, as 23 | * described in the [search()]{@link QuickScore#search} method. 24 | * 25 | * @param {string} string The string to score. 26 | * @param {string} query The query string to score the `string` parameter against. 27 | * @param {Array} [matches] If supplied, the function should push 28 | * onto `matches` a tuple with start and end indexes for each substring range 29 | * of `string` that matches `query`. 30 | * @returns {number} A number between 0 and 1 that represents how well the 31 | * `query` matches the `string`. 32 | */ 33 | 34 | /** 35 | * @typedef {function(string): string} TransformStringFunction A function that 36 | * takes a `string` parameter and returns a transformed version of that string. 37 | * 38 | * @param {string} string The string to be transformed. 39 | * @returns {string} The string with the transform applied to it. 40 | */ 41 | 42 | /** 43 | * @typedef {string|string[]} KeyName A reference to an item's key to search. 44 | * The key names can point to a nested key by passing either a dot-delimited 45 | * string or an array of sub-keys that specify the path to the value. 46 | */ 47 | 48 | /** 49 | * @typedef {KeyName|{name: KeyName, scorer: ScorerFunction}} ItemKey A 50 | * reference to an item's key to search. This type can also include a custom 51 | * scoring function to use for the given key. 52 | * 53 | * @property {KeyName} [name] The name of a key to search. 54 | * @property {ScorerFunction} [scorer] The function that will be used to score 55 | * the named string. 56 | */ 57 | 58 | /** 59 | * @typedef {object} Options An object specifying various options that can 60 | * customize QuickScore's scoring behavior. 61 | * 62 | * @property {Array} [keys] An array that specifies which keys to 63 | * search. 64 | * @property {string} [sortKey] The name of the key that will be used to sort 65 | * items with identical scores. 66 | * @property {number} [minimumScore] The minimum score an item must have to 67 | * appear in the results returned from `search()`. 68 | * @property {TransformStringFunction} [transformString] A function that takes 69 | * a `string` parameter and returns a transformed version of that string. 70 | * @property {ScorerFunction} [scorer] A function that takes `string` and 71 | * `query` parameters and returns a floating point number between 0 and 1 that 72 | * represents how well the `query` matches the `string`. 73 | * @property {Config} [config] An object that is passed to the scorer function 74 | * to further customize its behavior. 75 | */ 76 | 77 | /** 78 | * @typedef {object} ScoredString An object representing the results of scoring 79 | * an `items` array that contains strings. 80 | * 81 | * @property {string} item The string that was scored. 82 | * @property {number} score The floating point score of the string for the 83 | * current query. 84 | * @property {Array} matches An array of tuples that specify the 85 | * character ranges where the query matched the string. 86 | */ 87 | 88 | /** 89 | * @typedef {object} ScoredObject An object representing the results of scoring 90 | * an `items` array that contains objects. 91 | * 92 | * @property {object} item The object that was scored. 93 | * @property {number} score The highest score from among the individual key scores. 94 | * @property {string} scoreKey The name of the key with the highest score, 95 | * which will be an empty string if they're all zero. 96 | * @property {string} scoreValue The value of the key with the highest score, 97 | * which makes it easier to access if it's a nested string. 98 | * @property {object} scores A hash of the individual scores for each key. 99 | * @property {object} matches A hash of arrays that specify the character 100 | * ranges of the query match for each key. 101 | * @property {object} _ An internal cache of the transformed versions of this 102 | * item's strings and other metadata, which can be ignored. 103 | */ 104 | -------------------------------------------------------------------------------- /src/quick-score.js: -------------------------------------------------------------------------------- 1 | import {Range} from "./range"; 2 | import {createConfig, DefaultConfig} from "./config"; 3 | 4 | 5 | /** 6 | * Scores a string against a query. 7 | * 8 | * @param {string} string The string to score. 9 | * 10 | * @param {string} query The query string to score the `string` parameter against. 11 | * 12 | * @param {Array} [matches] If supplied, `quickScore()` will push onto 13 | * `matches` an array with start and end indexes for each substring range of 14 | * `string` that matches `query`. These indexes can be used to highlight the 15 | * matching characters in an auto-complete UI. 16 | * 17 | * @param {string} [transformedString] A transformed version of the string that 18 | * will be used for matching. This defaults to a lowercase version of `string`, 19 | * but it could also be used to match against a string with all the diacritics 20 | * removed, so an unaccented character in the query would match an accented one 21 | * in the string. 22 | * 23 | * @param {string} [transformedQuery] A transformed version of `query`. The 24 | * same transformation applied to `transformedString` should be applied to this 25 | * parameter, or both can be left as `undefined` for the default lowercase 26 | * transformation. 27 | * 28 | * @param {object} [config] A configuration object that can modify how the 29 | * `quickScore` algorithm behaves. 30 | * 31 | * @param {Range} [stringRange] The range of characters in `string` that should 32 | * be checked for matches against `query`. Defaults to the entire `string` 33 | * parameter. 34 | * 35 | * @returns {number} A number between 0 and 1 that represents how well the 36 | * `query` matches the `string`. 37 | */ 38 | export function quickScore( 39 | string, 40 | query, 41 | matches, 42 | transformedString = string.toLocaleLowerCase(), 43 | transformedQuery = query.toLocaleLowerCase(), 44 | config = DefaultConfig, 45 | stringRange = new Range(0, string.length)) 46 | { 47 | let iterations = 0; 48 | 49 | if (query) { 50 | return calcScore(stringRange, new Range(0, query.length), new Range()); 51 | } else { 52 | return config.emptyQueryScore; 53 | } 54 | 55 | 56 | function calcScore( 57 | searchRange, 58 | queryRange, 59 | fullMatchedRange) 60 | { 61 | if (!queryRange.length) { 62 | // deduct some points for all remaining characters 63 | return config.ignoredScore; 64 | } else if (queryRange.length > searchRange.length) { 65 | return 0; 66 | } 67 | 68 | const initialMatchesLength = matches && matches.length; 69 | 70 | for (let i = queryRange.length; i > 0; i--) { 71 | if (iterations > config.maxIterations) { 72 | // a long query that matches the string except for the last 73 | // character can generate 2^queryLength iterations of this 74 | // loop before returning 0, so short-circuit that when we've 75 | // seen too many iterations (bit of an ugly kludge, but it 76 | // avoids locking up the UI if the user somehow types an 77 | // edge-case query) 78 | return 0; 79 | } 80 | 81 | iterations++; 82 | 83 | const querySubstring = transformedQuery.substring(queryRange.location, queryRange.location + i); 84 | // reduce the length of the search range by the number of chars 85 | // we're skipping in the query, to make sure there's enough string 86 | // left to possibly contain the skipped chars 87 | const matchedRange = getRangeOfSubstring(transformedString, querySubstring, 88 | new Range(searchRange.location, searchRange.length - queryRange.length + i)); 89 | 90 | if (!matchedRange.isValid()) { 91 | // we didn't find the query substring, so try again with a 92 | // shorter substring 93 | continue; 94 | } 95 | 96 | if (!fullMatchedRange.isValid()) { 97 | fullMatchedRange.location = matchedRange.location; 98 | } else { 99 | fullMatchedRange.location = Math.min(fullMatchedRange.location, matchedRange.location); 100 | } 101 | 102 | fullMatchedRange.max(matchedRange.max()); 103 | 104 | if (matches) { 105 | matches.push(matchedRange.toArray()); 106 | } 107 | 108 | const remainingSearchRange = new Range(matchedRange.max(), searchRange.max() - matchedRange.max()); 109 | const remainingQueryRange = new Range(queryRange.location + i, queryRange.length - i); 110 | const remainingScore = calcScore(remainingSearchRange, remainingQueryRange, fullMatchedRange); 111 | 112 | if (remainingScore) { 113 | let score = remainingSearchRange.location - searchRange.location; 114 | // default to true since we only want to apply a discount if 115 | // we hit the final else clause below, and we won't get to 116 | // any of them if the match is right at the start of the 117 | // searchRange 118 | let skippedSpecialChar = true; 119 | const useSkipReduction = config.useSkipReduction(string, query, 120 | remainingScore, remainingSearchRange, searchRange, 121 | remainingSearchRange, matchedRange, fullMatchedRange); 122 | 123 | if (matchedRange.location > searchRange.location) { 124 | // some letters were skipped when finding this match, so 125 | // adjust the score based on whether spaces or capital 126 | // letters were skipped 127 | if (useSkipReduction && 128 | config.wordSeparators.indexOf(string[matchedRange.location - 1]) > -1) { 129 | for (let j = matchedRange.location - 2; j >= searchRange.location; j--) { 130 | if (config.wordSeparators.indexOf(string[j]) > -1) { 131 | score--; 132 | } else { 133 | score -= config.skippedScore; 134 | } 135 | } 136 | } else if (useSkipReduction && 137 | config.uppercaseLetters.indexOf(string[matchedRange.location]) > -1) { 138 | for (let j = matchedRange.location - 1; j >= searchRange.location; j--) { 139 | if (config.uppercaseLetters.indexOf(string[j]) > -1) { 140 | score--; 141 | } else { 142 | score -= config.skippedScore; 143 | } 144 | } 145 | } else { 146 | // reduce the score by the number of chars we've 147 | // skipped since the beginning of the search range 148 | score -= matchedRange.location - searchRange.location; 149 | skippedSpecialChar = false; 150 | } 151 | } 152 | 153 | score += config.adjustRemainingScore(string, 154 | query, remainingScore, skippedSpecialChar, searchRange, 155 | remainingSearchRange, matchedRange, fullMatchedRange); 156 | score /= searchRange.length; 157 | 158 | return score; 159 | } else if (matches) { 160 | // the remaining query does not appear in the remaining 161 | // string, so strip off any matches we've added during the 162 | // current call, as they'll be invalid when we start over 163 | // with a shorter piece of the query 164 | matches.length = initialMatchesLength; 165 | } 166 | } 167 | 168 | return 0; 169 | } 170 | } 171 | 172 | // make createConfig() available on quickScore so that the QuickScore 173 | // constructor has access to it 174 | quickScore.createConfig = createConfig; 175 | 176 | 177 | function getRangeOfSubstring( 178 | string, 179 | query, 180 | searchRange) 181 | { 182 | const index = string.indexOf(query, searchRange.location); 183 | const result = new Range(); 184 | 185 | if (index > -1 && index < searchRange.max()) { 186 | result.location = index; 187 | result.length = query.length; 188 | } 189 | 190 | return result; 191 | } 192 | -------------------------------------------------------------------------------- /src/range.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A class representing a half-open interval of characters. A range's `location` 3 | * property and `max()` value can be used as arguments for the `substring()` 4 | * method to extract a range of characters. 5 | */ 6 | export class Range { 7 | /** 8 | * @memberOf Range.prototype 9 | * @member {number} location Starting index of the range. 10 | */ 11 | 12 | /** 13 | * @memberOf Range.prototype 14 | * @member {number} length Number of characters in the range. 15 | */ 16 | 17 | /** 18 | * @param {number} [location=-1] Starting index of the range. 19 | * @param {number} [length=0] Number of characters in the range. 20 | */ 21 | constructor( 22 | location = -1, 23 | length = 0) 24 | { 25 | this.location = location; 26 | this.length = length; 27 | } 28 | 29 | 30 | /* eslint no-inline-comments: 0 */ 31 | /** 32 | * Gets the end index of the range, which indicates the character 33 | * immediately after the last one in the range. 34 | * 35 | * @returns {number} 36 | */ 37 | /** 38 | * Sets the end index of the range, which indicates the character 39 | * immediately after the last one in the range. 40 | * 41 | * @param {number} [value] End of the range. 42 | * 43 | * @returns {number} 44 | */ 45 | max( 46 | value) 47 | { 48 | if (typeof value == "number") { 49 | this.length = value - this.location; 50 | } 51 | 52 | // the NSMaxRange() function in Objective-C returns this value 53 | return this.location + this.length; 54 | } 55 | 56 | 57 | /** 58 | * Returns whether the range contains a location >= 0. 59 | * 60 | * @returns {boolean} 61 | */ 62 | isValid() 63 | { 64 | return (this.location > -1); 65 | } 66 | 67 | 68 | /** 69 | * Returns an array of the range's start and end indexes. 70 | * 71 | * @returns {RangeTuple} 72 | */ 73 | toArray() 74 | { 75 | return [this.location, this.max()]; 76 | } 77 | 78 | 79 | /** 80 | * Returns a string representation of the range's open interval. 81 | * 82 | * @returns {string} 83 | */ 84 | toString() 85 | { 86 | if (this.location == -1) { 87 | return "invalid range"; 88 | } else { 89 | return "[" + this.location + "," + this.max() + ")"; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/QuickScore.test.js: -------------------------------------------------------------------------------- 1 | import {QuickScore, BaseConfig, quickScore} from "../src"; 2 | import {clone, compareLowercase} from "./utils"; 3 | import Tabs from "./tabs"; 4 | 5 | 6 | const originalTabs = clone(Tabs); 7 | const nestedTabs = Tabs.map(tab => { 8 | const nestedTab = {}; 9 | 10 | // only add a key if the original tab had it, so we can test items 11 | // with missing keys 12 | tab.hasOwnProperty("title") && (nestedTab.title = tab.title); 13 | tab.hasOwnProperty("url") && (nestedTab.nested = { path: { url: tab.url } }); 14 | 15 | return nestedTab; 16 | }); 17 | const nestedPathArray = ["nested", "path", "url"]; 18 | const nestedPathString = nestedPathArray.join("."); 19 | 20 | 21 | describe("QuickScore tests", function() { 22 | const Strings = ["thought", "giraffe", "GitHub", "hello, Garth"]; 23 | const originalStrings = clone(Strings); 24 | 25 | test("Basic QuickScore.search() test", () => { 26 | const results = new QuickScore(Strings).search("gh"); 27 | 28 | expect(results[0].item).toBe("GitHub"); 29 | expect(results[0]._).toBe("github"); 30 | expect(results[0].matches).toEqual([[0, 1], [3, 4]]); 31 | 32 | // by default, zero-scored items should not be returned 33 | expect(results[results.length - 1].score).toBeGreaterThan(0); 34 | }); 35 | 36 | test("Default scorer vs. options", () => { 37 | const defaultScorer = new QuickScore(Strings); 38 | const qsScorer = new QuickScore(Strings, { scorer: quickScore }); 39 | const query = "gh"; 40 | 41 | expect(defaultScorer.search(query)).toEqual(qsScorer.search(query)); 42 | }); 43 | 44 | test("Empty query returns 0 scores, and sorted alphabetically", () => { 45 | const results = new QuickScore(Strings).search(""); 46 | const sortedStrings = clone(Strings).sort(compareLowercase); 47 | 48 | expect(results[0].score).toBe(0); 49 | expect(results.map(({item}) => item)).toEqual(sortedStrings); 50 | }); 51 | 52 | test("Empty QuickScore", () => { 53 | const qs = new QuickScore(); 54 | 55 | expect(qs.search("")).toEqual([]); 56 | }); 57 | 58 | test("Strings is unmodified", () => { 59 | expect(Strings).toEqual(originalStrings); 60 | }) 61 | }); 62 | 63 | 64 | describe("Tabs scoring", function() { 65 | function createValidator( 66 | tabs, 67 | keys) 68 | { 69 | // set minimumScore to -1 so that non-matching items with 0 scores 70 | // are returned 71 | const scorer = new QuickScore(tabs, { keys, minimumScore: -1 }); 72 | const tabCount = tabs.length; 73 | 74 | return function validator(query, matchCount, firstTitle, scoreKey) 75 | { 76 | const results = scorer.search(query); 77 | const nonmatches = results.filter(({score}) => score === 0); 78 | const nonmatchingTitles = nonmatches.map(({item: {title}}) => title); 79 | 80 | expect(results.length).toBe(tabCount); 81 | expect(tabCount - nonmatches.length).toBe(matchCount); 82 | expect(results[0].item.title).toBe(firstTitle); 83 | expect(results[0].scoreKey).toBe(scoreKey); 84 | 85 | // make sure the 0-scored objects are sorted case-insensitively 86 | // on their titles 87 | expect(nonmatchingTitles).toEqual(nonmatchingTitles.slice().sort(compareLowercase)); 88 | 89 | // make sure items with an undefined default key ("title", in 90 | // this case) are sorted to the end 91 | expect(nonmatchingTitles[nonmatchingTitles.length - 1]).toBe(undefined); 92 | } 93 | } 94 | 95 | const expectedResults = [ 96 | ["qk", 7, "QuicKey – The quick tab switcher - Chrome Web Store", "title"], 97 | ["dean", 12, "Bufala Negra – Garden & Gun", "url"], 98 | ["face", 10, "Facebook", "title"], 99 | ["", 0, "Best Practices - Sharing", ""] 100 | ]; 101 | const nestedExpectedResults = clone(expectedResults); 102 | 103 | // change the expected scoreKey from "url" to "nested.path.url" for the 104 | // second result when the tabs have a nested url key 105 | nestedExpectedResults[1][3] = nestedPathString; 106 | 107 | test.each(expectedResults)( 108 | 'Score Tabs array for "%s"', 109 | createValidator(Tabs, ["title", "url"]) 110 | ); 111 | test.each(nestedExpectedResults)( 112 | 'Score nested Tabs array for "%s"', 113 | createValidator(nestedTabs, ["title", nestedPathString]) 114 | ); 115 | test.each(nestedExpectedResults)( 116 | 'Score nested Tabs array with an array key for "%s"', 117 | createValidator(nestedTabs, ["title", nestedPathArray]) 118 | ); 119 | }); 120 | 121 | 122 | describe("Options", function() { 123 | test("Passing keys in options object", () => { 124 | const query = "qk"; 125 | const qs = new QuickScore(Tabs, ["title", "url"]); 126 | const qsOptions = new QuickScore(Tabs, { keys: ["title", "url"] }); 127 | 128 | expect(qs.search(query)).toEqual(qsOptions.search(query)); 129 | }); 130 | 131 | test("Keys with dots in the name", () => { 132 | const dotKeyTabs = Tabs.map(tab => { 133 | const newTab = {}; 134 | 135 | tab.hasOwnProperty("title") && (newTab.title = tab.title); 136 | tab.hasOwnProperty("url") && (newTab.url = tab.url); 137 | newTab["title.url"] = `${tab.title}.${tab.url}`; 138 | 139 | return newTab; 140 | }); 141 | const query = "tabswitchldl"; 142 | const qsDotKeyArray = new QuickScore(dotKeyTabs, { keys: ["title", "url", ["title.url"]] }); 143 | const qsDotKeyString = new QuickScore(dotKeyTabs, { keys: ["title", "url", "title.url"] }); 144 | const arrayResults = qsDotKeyArray.search(query); 145 | const stringResults = qsDotKeyString.search(query); 146 | const [firstArrayResult] = arrayResults; 147 | 148 | // there's a tab listed twice in Tabs whose "title.url" value will 149 | // match the query, but only if the key name is wrapped in an array, 150 | // so that QuickScore doesn't try to split the name into a path. 151 | // nothing will match if we pass a dot-delimited string key. 152 | expect(arrayResults.length).toBe(2); 153 | expect(stringResults.length).toBe(0); 154 | 155 | // make sure the match is on "title.url" and that that key was not 156 | // treated as a dot path to a sub-key 157 | expect(firstArrayResult.scoreKey).toBe("title.url"); 158 | expect(firstArrayResult.scoreValue).toBe(`${firstArrayResult.item.title}.${firstArrayResult.item.url}`); 159 | expect(qsDotKeyArray.keys[2]).not.toHaveProperty("path"); 160 | }); 161 | 162 | test("Per-key scorer", () => { 163 | const qs = new QuickScore(Tabs, [ 164 | { 165 | name: "title", 166 | scorer: () => 1 167 | }, 168 | { 169 | name: "url", 170 | scorer: () => 0 171 | } 172 | ]); 173 | const [firstItem] = qs.search("qk"); 174 | 175 | // since all the scores are the same, the results should be alphabetized 176 | expect(firstItem.item.title).toBe("Best Practices - Sharing"); 177 | expect(firstItem.scores.title).toBe(1); 178 | expect(firstItem.scores.url).toBe(0); 179 | }); 180 | 181 | test("Per-key scorer with dot paths", () => { 182 | const qs = new QuickScore(nestedTabs, [ 183 | { 184 | name: "title", 185 | scorer: () => 0 186 | }, 187 | { 188 | name: nestedPathString, 189 | scorer: (string, query) => string.indexOf(query) === 0 ? 1 : 0 190 | } 191 | ]); 192 | const results = qs.search("view-source"); 193 | const [firstItem] = results; 194 | 195 | // only one tab has a url that starts with "view-source" 196 | expect(results.length).toBe(1); 197 | expect(firstItem.item.title).toBe("view-source:https://fwextensions.github.io/QuicKey/ctrl-tab/"); 198 | expect(firstItem.scores.title).toBe(0); 199 | expect(firstItem.scores[nestedPathString]).toBe(1); 200 | }); 201 | 202 | test("Keys is an empty array", () => { 203 | // add a tab that has a different key than the others with a value 204 | // that equals the query, so it'll be the top match 205 | const query = "qk"; 206 | const tabs = [{ foo: query }].concat(Tabs); 207 | const qs = new QuickScore(tabs, { 208 | keys: [] 209 | }); 210 | const results = qs.search(query); 211 | const [firstItem] = results; 212 | 213 | expect(results.filter(({score}) => score).length).toBe(8); 214 | expect(firstItem.scoreValue).toBe(query); 215 | expect(firstItem.scoreKey).toBe("foo"); 216 | expect(firstItem.score).toBe(1); 217 | expect(firstItem.matches[firstItem.scoreKey]).toEqual([[0, 2]]); 218 | }); 219 | 220 | test("Call setKeys() and setItems() after constructor", () => { 221 | const qs = new QuickScore(); 222 | 223 | qs.setKeys(["title", "url"]); 224 | qs.setItems(Tabs); 225 | 226 | const results = qs.search("qk"); 227 | const [firstItem] = results; 228 | 229 | expect(results.filter(({score}) => score).length).toBe(7); 230 | expect(firstItem.scoreValue).toBe("QuicKey – The quick tab switcher - Chrome Web Store"); 231 | expect(firstItem.scoreKey).toBe("title"); 232 | expect(firstItem.score).toBeNearly(.90098); 233 | expect(firstItem.matches[firstItem.scoreKey]).toEqual([[0, 1], [4, 5]]); 234 | }); 235 | 236 | test("Pass sortKey option that isn't the first string in keys", () => { 237 | const qs = new QuickScore(Tabs, { 238 | keys: ["title", "url"], 239 | sortKey: "url" 240 | }); 241 | const results = qs.search(""); 242 | const [firstItem] = results; 243 | const [lastItem] = results.slice(-1); 244 | 245 | expect(results.length).toBe(Tabs.length); 246 | expect(firstItem.item.url.indexOf("chrome")).toBe(0); 247 | expect(firstItem.score).toBe(0); 248 | expect(lastItem.url).toEqual(undefined); 249 | }); 250 | 251 | test("Pass sortKey option that resolves to undefined", () => { 252 | const qs = new QuickScore(Tabs, { 253 | keys: ["title", "url"], 254 | sortKey: "foo" 255 | }); 256 | const results = qs.search(""); 257 | const [firstItem] = results; 258 | const [lastItem] = results.slice(-1); 259 | 260 | // since the query is empty and the sortKey doesn't exist, the order 261 | // of the results should be the same as the original items array 262 | expect(results.length).toBe(Tabs.length); 263 | expect(firstItem.item.title).toBe(Tabs[0].title); 264 | expect(firstItem.score).toBe(0); 265 | expect(lastItem.item.title).toBe(Tabs.slice(-1)[0].title); 266 | }); 267 | 268 | test("Config with useSkipReduction off", () => { 269 | const qs = new QuickScore(Tabs, { 270 | keys: ["title", "url"], 271 | config: { 272 | useSkipReduction: () => false 273 | } 274 | }); 275 | const results = qs.search("qk"); 276 | const [firstItem] = results; 277 | 278 | expect(results.filter(({score}) => score).length).toBe(7); 279 | expect(firstItem.item.title).toBe("Quokka.js: Configuration"); 280 | expect(firstItem.scoreKey).toBe("title"); 281 | expect(firstItem.score).toBe(0.4583333333333333); 282 | expect(firstItem.matches.title).toEqual([[0, 1], [3, 4]]); 283 | }); 284 | 285 | test("scorer with no createConfig()", () => { 286 | const qs = new QuickScore(Tabs, { 287 | keys: ["title", "url"], 288 | scorer: () => 1 289 | }); 290 | const [firstItem] = qs.search(""); 291 | 292 | // since all the scores are the same, the results should be alphabetized 293 | expect(firstItem.item.title).toBe("Best Practices - Sharing"); 294 | expect(firstItem.scores.title).toBe(1); 295 | expect(firstItem.scores.url).toBe(1); 296 | }); 297 | 298 | test("BaseConfig", () => { 299 | const qs = new QuickScore(Tabs, { 300 | keys: ["title", "url"], 301 | config: BaseConfig 302 | }); 303 | const qsDefault = new QuickScore(Tabs, ["title", "url"]); 304 | const [firstItem] = qs.search("mail"); 305 | const [firstItemDefault] = qsDefault.search("mail"); 306 | 307 | expect(firstItem.item.title).toBe("facebook/immutable-js: Immutable persistent data collections for Javascript which increase efficiency and simplicity."); 308 | expect(firstItem.scores.title).toBeNearly(0.74060); 309 | expect(firstItem.scores.url).toBeNearly(0.34250); 310 | expect(firstItem.score).toBeGreaterThan(firstItemDefault.score); 311 | }); 312 | }); 313 | 314 | 315 | test("Nested keys edge cases", () => { 316 | const items = [ 317 | { 318 | title: "zero", 319 | nested: 0 320 | }, 321 | { 322 | title: "one", 323 | nested: 1 324 | }, 325 | { 326 | title: "null", 327 | nested: null 328 | }, 329 | { 330 | title: "object", 331 | nested: {} 332 | }, 333 | { 334 | title: "undefined" 335 | }, 336 | { 337 | title: "empty string", 338 | nested: { 339 | value: "" 340 | } 341 | }, 342 | { 343 | title: "true", 344 | nested: true 345 | }, 346 | { 347 | title: "filled string", 348 | nested: { 349 | value: "foo" 350 | } 351 | } 352 | ]; 353 | const qs = new QuickScore(items, { 354 | keys: ["title", "nested.value"], 355 | minimumScore: -1 356 | }); 357 | const results = qs.search("filled"); 358 | const nonMatchingResults = results.filter(item => !item._.hasOwnProperty("nested.value")); 359 | 360 | // make sure the lowercase versions of all the empty or undefined string 361 | // values are undefined 362 | expect(nonMatchingResults.length).toBe(items.length - 1); 363 | expect(results[0].item.title).toBe("filled string"); 364 | }); 365 | 366 | 367 | test("Tabs is unmodified", () => { 368 | // across all of the tests above, Tabs should remain unmodified 369 | expect(Tabs).toEqual(originalTabs); 370 | }); 371 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import {QuickScore, quickScore, Range} from "../src"; 2 | 3 | 4 | describe("Exported functions test", function() { 5 | test.each([ 6 | ["QuickScore", 0, QuickScore], 7 | ["quickScore", 3, quickScore], 8 | ["Range", 0, Range] 9 | ])("%s() should have %i arguments", (name, arity, fn) => { 10 | expect(typeof fn).toBe("function"); 11 | expect(fn.length).toBe(arity); 12 | expect(fn.name).toBe(name); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/quick-score.test.js: -------------------------------------------------------------------------------- 1 | import {quickScore} from "../src/quick-score"; 2 | import {QuicksilverConfig} from "../src/config"; 3 | import {Range} from "../src/range"; 4 | 5 | 6 | const MaxDifference = .00001; 7 | const ScoreTestTitle = 'quickScore("%s", "%s")'; 8 | const HitsTestTitle = 'quickScore("%s", "%s", [])'; 9 | 10 | 11 | function scoreNearly( 12 | string, 13 | query, 14 | expected, 15 | maxDifference = MaxDifference) 16 | { 17 | // the built-in .toBeCloseTo() rounds the result, so .18181818181818182 18 | // rounds to .18182, which is not close to .18181 in 5 digits. so use 19 | // the helper defined above to check that the result is within .00001 of 20 | // expected, which is what the Quicksilver tests look for. 21 | expect(quickScore(string, query, undefined, undefined, undefined, 22 | QuicksilverConfig)).toBeNearly(expected, maxDifference); 23 | } 24 | 25 | 26 | describe("Quicksilver short string", () => { 27 | const str = "Test string"; 28 | 29 | test.each([ 30 | [str, "t", 0.90909], 31 | [str, "ts", 0.88182], 32 | [str, "te", 0.91818], 33 | [str, "tet", 0.89091], 34 | [str, "str", 0.91818], 35 | [str, "tstr", 0.93182], 36 | [str, "ng", 0.59091] 37 | ])(ScoreTestTitle, scoreNearly); 38 | }); 39 | 40 | 41 | describe("Quicksilver long string", () => { 42 | const str = "This is a really long test string for testing"; 43 | 44 | test.each([ 45 | [str, "t", 0.90222], 46 | [str, "ts", 0.88666], 47 | [str, "te", 0.80777], 48 | [str, "tet", 0.80111], 49 | [str, "str", 0.78555], 50 | [str, "tstr", 0.78889], 51 | [str, "testi", 0.74000], 52 | [str, "for", 0.75888], 53 | [str, "ng", 0.73556] 54 | ])(ScoreTestTitle, scoreNearly); 55 | }); 56 | 57 | 58 | describe("Quicksilver hit indices", function() { 59 | const str = "This excellent string tells us an interesting story"; 60 | const strRange = new Range(0, 27); // ^--- range ends here initially 61 | const abbr = "test"; 62 | const hits = []; 63 | 64 | afterEach(() => { 65 | hits.length = 0; 66 | strRange.length += 4; 67 | }); 68 | 69 | test.each([ 70 | [str, abbr, 0.90185, [[0, 1], [5, 6], [15, 17]]], 71 | [str, abbr, 0.90161, [[0, 1], [5, 6], [15, 17]]], 72 | [str, abbr, 0.90143, [[0, 1], [5, 6], [15, 17]]], 73 | [str, abbr, 0.72949, [[22, 24], [26, 27], [36, 37]]], 74 | [str, abbr, 0.69884, [[22, 24], [40, 42]]], 75 | [str, abbr, 0.71595, [[22, 24], [40, 42]]], 76 | [str, abbr, 0.73039, [[22, 24], [40, 42]]] 77 | ])(HitsTestTitle, (string, query, expectedScore, expectedHits) => { 78 | expect(quickScore(string, query, hits, undefined, undefined, 79 | QuicksilverConfig, strRange)).toBeNearly(expectedScore, MaxDifference); 80 | expect(hits).toEqual(expectedHits); 81 | }); 82 | }); 83 | 84 | 85 | describe("Match indices", function() { 86 | test.each([ 87 | // the first test ensures that hits from early partial matches don't 88 | // linger in the matches array 89 | ["This excellent string tells us an intes foo bar baz", "test", 90 | [], 0.76961, [[22, 24], [26, 27], [36, 37]]], 91 | ["This excellent string tells us an interesting story", "test", 92 | [], 0.73039, [[22, 24], [40, 42]]], 93 | // v0.0.5 was incorrectly throwing away earlier matches when a zero 94 | // remainingScore was found, so the match indices were wrong. this 95 | // test verifies that the right indices are returned. 96 | ["https://raw.githubusercontent.com/gaearon/react-hot-loader/master/README.md", "release", 97 | [], 0.76333, [[42, 44], [52, 53], [56, 57], [60, 62], [63, 64]]], 98 | // test the same string, but don't pass an array for collecting the 99 | // matches this time so that that code branch is covered 100 | ["https://raw.githubusercontent.com/gaearon/react-hot-loader/master/README.md", "release", 101 | undefined, 0.76333, undefined] 102 | ])(HitsTestTitle, (string, query, hits, expectedScore, expectedHits) => { 103 | expect(quickScore(string, query, hits, undefined, undefined, 104 | QuicksilverConfig)).toBeNearly(expectedScore, MaxDifference); 105 | expect(hits).toEqual(expectedHits); 106 | }); 107 | }); 108 | 109 | 110 | describe("Uppercase matches", function() { 111 | test.each([ 112 | ["QuicKey", "qk", 0.90714], 113 | ["WhatIsQuicKey?", "qk", 0.76071], 114 | ["QuicKey", "QuicKey", 1], 115 | ["quickly", "qk", 0.75714] 116 | ])(ScoreTestTitle, scoreNearly); 117 | }); 118 | 119 | 120 | describe("Word separator matches", function() { 121 | test.each([ 122 | ["react-hot-loader", "rhl", 0.91250], 123 | ["are there walls?", "rhl", 0.66875] 124 | ])(ScoreTestTitle, scoreNearly); 125 | }); 126 | 127 | 128 | describe("Zero scores", function() { 129 | test.each([ 130 | ["foo", "foobar", 0], 131 | ["", "foobar", 0], 132 | ["foobar", "", 0], 133 | ["", "", 0] 134 | ])(ScoreTestTitle, (string, query, expectedScore) => { 135 | expect(quickScore(string, query)).toBe(expectedScore); 136 | }); 137 | 138 | // do the same test with the QuicksilverConfig, which returns .9 for 139 | // empty queries 140 | test.each([ 141 | ["foo", "foobar", 0], 142 | ["", "foobar", 0], 143 | ["foobar", "", .9], 144 | ["", "", .9] 145 | ])(ScoreTestTitle, (string, query, expectedScore) => { 146 | expect(quickScore(string, query, undefined, undefined, undefined, 147 | QuicksilverConfig)).toBe(expectedScore); 148 | }); 149 | }); 150 | 151 | 152 | describe("Search ranges", function() { 153 | test.each([ 154 | ["bar", "bar", new Range(0, 3), 1], 155 | ["bar", "bar", new Range(1, 3), 0] 156 | ])('quickScore("%s", "%s", null, QuicksilverConfig, %s)', (string, query, range, expectedScore) => { 157 | expect(quickScore(string, query, undefined, undefined, undefined, 158 | QuicksilverConfig, range)).toBe(expectedScore); 159 | }); 160 | }); 161 | 162 | 163 | describe("Edge cases", () => { 164 | test("16-character edge-case query should return a score", () => { 165 | const maxQueryLength = 16; 166 | const alphabet = "abcdefghijklmnopqrstuvwxyz"; 167 | const goodQuery = alphabet.slice(0, maxQueryLength); 168 | const goodString = goodQuery.split("").join("|") + goodQuery.slice(0, -1) + "@"; 169 | const tooLongQuery = alphabet.slice(0, maxQueryLength + 1); 170 | const tooLongString = tooLongQuery.split("").join("|") + tooLongQuery.slice(0, -1) + "@"; 171 | const unlimitedIterationsConfig = quickScore.createConfig({ maxIterations: Infinity }); 172 | 173 | // both strings include almost all of the query as a sequence at the 174 | // end of the string, and the entire query separated by | at the 175 | // beginning. this will generate a worst-case 2^queryLength number 176 | // of loops before a match is found and a score returned. the 177 | // goodQuery will fit within config.maxIterations, but tooLongQuery 178 | // will hit the limit and return 0, even though a match would be 179 | // found if it was allowed to continue looping. 180 | expect(quickScore(goodString, goodQuery)).toBeNearly(.06526); 181 | expect(quickScore(tooLongString, tooLongQuery)).toBe(0); 182 | expect(quickScore(tooLongString, tooLongQuery, undefined, undefined, 183 | undefined, unlimitedIterationsConfig)).toBeNearly(.06126); 184 | }); 185 | 186 | test("quickScore() with undefined params throws", () => { 187 | // all the parameters have default values 188 | expect(() => quickScore()).toThrow(); 189 | expect(() => quickScore(undefined, undefined)).toThrow(); 190 | }); 191 | }) 192 | 193 | 194 | // these older scores, from not dividing the reduction of the remaining score 195 | // by half, match what the old NS Quicksilver code returns. the scores were 196 | // changed in TestQSSense.m in this commit: 197 | // https://github.com/quicksilver/Quicksilver/commit/0f2b4043fafbfc4b1263b7807504eb1b3baaeab8#diff-86c92ca75387e03f87312001fe115fb9 198 | /* 199 | describe("Old NSString Quicksilver short string", () => { 200 | const str = "Test string"; 201 | 202 | test.each([ 203 | [str, "t", 0.90909], 204 | [str, "ts", 0.83636], 205 | [str, "te", 0.91818], 206 | [str, "tet", 0.84545], 207 | [str, "str", 0.91818], 208 | [str, "tstr", 0.93181], 209 | [str, "ng", 0.18181] 210 | ])("score('%s', '%s')", scoreNearly); 211 | }); 212 | 213 | describe("Old NSString Quicksilver long string", () => { 214 | const str = "This is a really long test string for testing"; 215 | 216 | test.each([ 217 | [str, "t", 0.90222], 218 | [str, "ts", 0.86444], 219 | [str, "te", 0.80777], 220 | [str, "tet", 0.79000], 221 | [str, "str", 0.78555], 222 | [str, "tstr", 0.78888], 223 | [str, "testi", 0.74000], 224 | [str, "for", 0.75888], 225 | [str, "ng", 0.52444] 226 | ])("score('%s', '%s')", scoreNearly); 227 | }); 228 | */ 229 | 230 | 231 | // these tests worked when we changed quick-score.js to behave like QSSense.m as 232 | // of 2018-07-25, which had some subtle bugs. the scores and hit arrays will no 233 | // longer exactly match, now that we've changed the code back to remove the bugs. 234 | /* 235 | describe("Buggy Quicksilver short string", () => { 236 | const string = "Test string"; 237 | 238 | test.each([ 239 | [string, "t", 0.90909], 240 | [string, "ts", 0.92727], 241 | [string, "te", 0.91818], 242 | [string, "tet", 0.93636], 243 | [string, "str", 0.91818], 244 | [string, "tstr", 0.79090], 245 | [string, "ng", 0.63636] 246 | ])("score('%s', '%s')", scoreNearly); 247 | }); 248 | 249 | describe("Buggy Quicksilver long string", () => { 250 | const str = "This is a really long test string for testing"; 251 | 252 | test.each([ 253 | [str, "t", 0.90222], 254 | [str, "ts", 0.88666], 255 | [str, "te", 0.80777], 256 | [str, "tet", 0.81222], 257 | [str, "str", 0.78555], 258 | [str, "tstr", 0.67777], 259 | [str, "testi", 0.74000], 260 | [str, "for", 0.75888], 261 | [str, "ng", 0.74666] 262 | ])("score('%s', '%s')", scoreNearly); 263 | }); 264 | 265 | describe("Buggy Quicksilver hitmask", function() { 266 | const str = "This excellent string tells us an interesting story"; 267 | const strRange = new Range(0, 27); // ^--- range ends here initially 268 | const abbr = "test"; 269 | const hits = []; 270 | const sortNumbers = (a, b) => a - b; 271 | const hitsSet = new Set(); 272 | 273 | afterEach(() => { 274 | hits.length = 0; 275 | strRange.length += 4; 276 | }); 277 | 278 | test.each([ 279 | [str, abbr, 0.74074, [0, 5, 15, 16, 22, 23]], 280 | [str, abbr, 0.76129, [0, 5, 15, 16, 22, 23, 26]], 281 | [str, abbr, 0.77714, [0, 5, 15, 16, 22, 23, 26]], 282 | [str, abbr, 0.74230, [0, 5, 15, 16, 22, 23, 26, 36]], 283 | [str, abbr, 0.69883, [0, 5, 15, 16, 22, 23, 26, 36, 40, 41]], 284 | [str, abbr, 0.71595, [0, 5, 15, 16, 22, 23, 26, 36, 40, 41]], 285 | [str, abbr, 0.73039, [0, 5, 15, 16, 22, 23, 26, 36, 40, 41]] 286 | ])("score('%s', '%s', '%f', '%o')", (string, query, expectedScore, expectedHits) => { 287 | expect(score(string, query, hits, true, strRange)).toBeNearly(expectedScore, MaxDifference); 288 | 289 | // to simulate the bug in TestQSSense.m where the NSMutableIndexSet 290 | // doesn't get reset after each test, we add each hit to a Set, so 291 | // it accumulates just one instance of each index 292 | hits.forEach(hit => { 293 | hitsSet.add(hit); 294 | }); 295 | expect(Array.from(hitsSet).sort(sortNumbers)).toEqual(expectedHits); 296 | }); 297 | }); 298 | */ 299 | -------------------------------------------------------------------------------- /test/range.test.js: -------------------------------------------------------------------------------- 1 | import {Range} from "../src/range"; 2 | 3 | 4 | test("new Range()", () => { 5 | const r = new Range(); 6 | 7 | expect(r.location).toBe(-1); 8 | expect(r.length).toBe(0); 9 | expect(r.max()).toBe(-1); 10 | expect(r.toString()).toBe("invalid range"); 11 | expect(r + "").toBe("invalid range"); 12 | expect(r.isValid()).toBe(false); 13 | }); 14 | 15 | test("new Range(0, 10)", () => { 16 | const r = new Range(0, 10); 17 | 18 | expect(r.location).toBe(0); 19 | expect(r.length).toBe(10); 20 | expect(r.max()).toBe(10); 21 | expect(r.toString()).toBe("[0,10)"); 22 | expect(r + "").toBe("[0,10)"); 23 | expect(r.isValid()).toBe(true); 24 | expect(r.toArray()).toEqual([0, 10]); 25 | }); 26 | 27 | test("Setting length with max()", () => { 28 | const r = new Range(0, 1); 29 | 30 | expect(r.max()).toBe(1); 31 | r.max(10); 32 | expect(r.max()).toBe(10); 33 | }); 34 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | expect.extend({ 2 | toBeNearly( 3 | received, 4 | argument, 5 | maxDifference = 0.00001) 6 | { 7 | const pass = Math.abs(received - argument) <= maxDifference; 8 | 9 | if (pass) { 10 | return { 11 | message: () => `expected ${received} not to be within ${maxDifference} of ${argument}`, 12 | pass: true 13 | }; 14 | } else { 15 | return { 16 | message: () => `expected ${received} to be within ${maxDifference} of ${argument}`, 17 | pass: false 18 | }; 19 | } 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /test/tabs.js: -------------------------------------------------------------------------------- 1 | // include some objects that are missing one or both keys, to test that 2 | // handling. also include the QuicKey one twice to check for duplicate 3 | // handling when sorting. 4 | export default [ 5 | { 6 | "title": "No URL" 7 | }, 8 | { 9 | "url": "http://no-title.com" 10 | }, 11 | { 12 | 13 | }, 14 | { 15 | title: "QuicKey – The quick tab switcher - Chrome Web Store", 16 | url: "chrome.google.com/webstore/detail/quickey-–-the-quick-tab-s/ldlghkoiihaelfnggonhjnfiabmaficg" 17 | }, 18 | { 19 | title: "QuicKey – The quick tab switcher - Chrome Web Store", 20 | url: "chrome.google.com/webstore/detail/quickey-–-the-quick-tab-s/ldlghkoiihaelfnggonhjnfiabmaficg" 21 | }, 22 | { 23 | title: "Bufala Negra – Garden & Gun", 24 | url: "gardenandgun.com/recipe/bufala-negra/?utm_source=twitter&utm_medium=socialmedia&utm_campaign=july2017_twitter" 25 | }, 26 | { 27 | title: "Issues · deanoemcke/thegreatsuspender", 28 | url: "github.com/deanoemcke/thegreatsuspender/issues" 29 | }, 30 | { 31 | title: "Mac Rumors: Apple Mac iOS Rumors and News You Care About", 32 | url: "macrumors.com/" 33 | }, 34 | { 35 | title: "minimal/styles.css at master · orderedlist/minimal", 36 | url: "github.com/orderedlist/minimal/blob/master/stylesheets/styles.css" 37 | }, 38 | { 39 | title: "GitHub - brillout/awesome-react-components: Catalog of React Components & Libraries", 40 | url: "github.com/brillout/awesome-react-components" 41 | }, 42 | { 43 | title: "True Hash Maps in JavaScript | Ryan Morr", 44 | url: "ryanmorr.com/true-hash-maps-in-javascript/?utm_source=javascriptweekly&utm_medium=email" 45 | }, 46 | { 47 | title: "clauderic/react-sortable-hoc: ✌️ A set of higher-order components to turn any list into an animated, touch-friendly, sortable list.", 48 | url: "github.com/clauderic/react-sortable-hoc" 49 | }, 50 | { 51 | title: "Station: Dark Nights | Hammahalle | Sisyphos | 13.o8.16 | Free Listening on SoundCloud on SoundCloud", 52 | url: "soundcloud.com/stations/track/raphaelhofman/dark-night-hammahalle-sisyphos-13o816" 53 | }, 54 | { 55 | title: "javascript - VirtualScroll rowRenderer method is called many times while scrolling - Stack Overflow", 56 | url: "stackoverflow.com/questions/37049280/virtualscroll-rowrenderer-method-is-called-many-times-while-scrolling" 57 | }, 58 | { 59 | title: "On high-DPI screens, onRowsRendered fires twice with an incorrect startIndex when scrolling via scrollToIndex · Issue #1015 · bvaughn/react-virtualized", 60 | url: "github.com/bvaughn/react-virtualized/issues/1015" 61 | }, 62 | { 63 | title: "electron/electron: Build cross platform desktop apps with JavaScript, HTML, and CSS", 64 | url: "https://github.com/electron/electron" 65 | }, 66 | { 67 | title: "Facebook", 68 | url: "https://www.facebook.com/" 69 | }, 70 | { 71 | title: "Immutable.js", 72 | url: "https://facebook.github.io/immutable-js/" 73 | }, 74 | { 75 | title: "facebook/immutable-js: Immutable persistent data collections for Javascript which increase efficiency and simplicity.", 76 | url: "https://github.com/facebook/immutable-js" 77 | }, 78 | { 79 | title: "Best Practices - Sharing", 80 | url: "https://developers.facebook.com/docs/sharing/best-practices/" 81 | }, 82 | { 83 | title: "view-source:https://fwextensions.github.io/QuicKey/ctrl-tab/", 84 | url: "view-source:https://fwextensions.github.io/QuicKey/ctrl-tab/" 85 | }, 86 | { 87 | title: "QuicKey | Jump between recent tabs in Chrome via keyboard or menu", 88 | url: "fwextensions.github.io/QuicKey/ctrl-tab/" 89 | }, 90 | { 91 | title: "Quokka.js: Configuration", 92 | url: "https://quokkajs.com/docs/configuration.html" 93 | }, 94 | { 95 | title: "Sharing Debugger - Facebook for Developers", 96 | url: "https://developers.facebook.com/tools/debug/sharing/?q=https%3A%2F%2Ffwextensions.github.io%2FQuicKey%2F" 97 | }, 98 | { 99 | title: "kaleido trays - Google Search", 100 | url: "https://www.google.com/search?q=kaleido+trays&rlz=1C1GGRV_enUS749US749&oq=kaleido+trays&aqs=chrome..69i64j0j5j0j5j0.3589j0j4&sourceid=chrome&ie=UTF-8" 101 | } 102 | ]; 103 | -------------------------------------------------------------------------------- /test/transform-string.test.js: -------------------------------------------------------------------------------- 1 | import {QuickScore} from "../src"; 2 | 3 | 4 | describe("Use transformString()", () => { 5 | const Strings = ["thought", "giraffe", "GitHub", "hello, Garth"]; 6 | 7 | function transformString( 8 | string) 9 | { 10 | return string.toLocaleUpperCase(); 11 | } 12 | 13 | test("Uppercase transformString() still matches all queries", () => { 14 | const lowercaseQS = new QuickScore(Strings); 15 | const uppercaseQS = new QuickScore(Strings, { transformString }); 16 | 17 | expect(lowercaseQS.search("gh").length).toBe(3); 18 | expect(uppercaseQS.search("gh").length).toBe(3); 19 | expect(lowercaseQS.search("GH").length).toBe(3); 20 | expect(uppercaseQS.search("GH").length).toBe(3); 21 | expect(uppercaseQS.search("")[0]._).toBe("GIRAFFE"); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | export function clone( 2 | obj) 3 | { 4 | return JSON.parse(JSON.stringify(obj)); 5 | } 6 | 7 | 8 | export function compareLowercase( 9 | a = "", 10 | b = "") 11 | { 12 | const lcA = a.toLocaleLowerCase(); 13 | const lcB = b.toLocaleLowerCase(); 14 | 15 | return lcA == lcB ? 0 : (lcA && lcA < lcB) ? -1 : 1; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist-dts", 4 | "target": "es2018", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "emitDeclarationOnly": true, 9 | "baseUrl": "src", 10 | "allowJs": true, 11 | "strict": true, 12 | }, 13 | "include": ["src/index.js"] 14 | } 15 | --------------------------------------------------------------------------------