├── .editorconfig ├── .gitignore ├── .jshintrc ├── .npmignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── bower.json ├── dist ├── jst9.js └── jst9.min.js ├── example ├── index.html └── words.json ├── gulpfile.js ├── package.json ├── src ├── jst9.js └── umdTemplate.js └── test ├── asyncTest.js ├── fixtures ├── lorem_tree.json └── server.js ├── normalSearchTest.js └── slackSearchTest.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig help us maintain consistent coding style between different editors. 2 | # 3 | # EditorConfig 4 | # http://editorconfig.org 5 | # 6 | root = true 7 | 8 | [*] 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | components 4 | *.log 5 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "undef": true, 3 | "predef": ["root", "axios"], 4 | "camelcase": true, 5 | "curly": true, 6 | "eqeqeq": true, 7 | "indent": 4, 8 | "quotmark": "single", 9 | "undef": true, 10 | "browser": true, 11 | "browserify": true, 12 | "node": true 13 | } 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | example 4 | docs 5 | .idea 6 | 7 | 8 | # don't ignore .npmignore files 9 | !.npmignore 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "0.11" 5 | - "0.10" 6 | - "iojs" 7 | - "iojs-v1.0.4" 8 | script: "make" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REPORTER = spec 2 | BIN = ./node_modules/.bin 3 | 4 | test: 5 | $(BIN)/mocha --reporter $(REPORTER) --ui bdd ./test/* 6 | 7 | .PHONY: test 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsT9 2 | 3 | [![Build Status](https://travis-ci.org/talyssonoc/jsT9.svg?branch=master)](https://travis-ci.org/talyssonoc/jsT9) [![Code Climate](https://codeclimate.com/github/talyssonoc/jsT9/badges/gpa.svg)](https://codeclimate.com/github/talyssonoc/jsT9) 4 | 5 | ## Installation 6 | 7 | With npm 8 | 9 | ``` 10 | $ npm install jst9 --save 11 | ``` 12 | 13 | With Bower 14 | 15 | ``` 16 | $ bower install jst9 --save 17 | ``` 18 | 19 | Or copy some of the files inside `dist` folder. 20 | On browsers, it exports the `jsT9` global. 21 | 22 | ## Usage 23 | 24 | To create a new jsT9 instance, you show use the constructor like this: 25 | 26 | ``` 27 | var tree = new jsT9(words[, settings]); 28 | ``` 29 | 30 | Where: 31 | 32 | - `words` can be: 33 | - An array of words, or 34 | - A string with the path of a JSON file with a field called 'words' containing the array of words (see the words.json example file). See `ready` method on the API below. 35 | - `settings` (optional) 36 | - `sort`: A `sort(A, B)` (__Default__: Alphabetical order) function that returns: 37 | - -1 if `A < B` 38 | - 1 if `A > B` 39 | - 0 if `A == B` 40 | - `maxAmount`: Default max amount of predictions to be returned (__Default__: `Infinity`). 41 | - `slackSearch`: Search words using [slack search](#how-slack-search-works) (__Default__: `true`). 42 | 43 | ## API 44 | 45 | - `predict(word)`: Return the predictions to the given word. 46 | - `addWord(word)`: Add an new word to the tree. 47 | - `ready(callback)`: Runs the callback when the tree is ready, useful when you're loading the words with JSON. 48 | 49 | ## How slack search works 50 | 51 | If no complete word in the tree matches the searched word, the slack search will remove the last character of the word, one by one, until it finds a match. 52 | 53 | Example: 54 | 55 | Given this word list: 56 | - List 57 | - Look 58 | - Loop 59 | 60 | If you try predict `Loo`, you'll get `["Look", "Loop"]`. 61 | 62 | But if you try predict `Lx`, the algorithm won't find a match, so it will remove the "x" and try to predict `L`, then you'll get `["List", "Look", "Loop"]`. 63 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jst9", 3 | "description": "A text-prediction JavaScript tool based on PATRICIA tree", 4 | "version": "0.4.0", 5 | "main": "dist/jst9.js", 6 | "keywords": [ 7 | "text prediction", 8 | "autocomplete", 9 | "tree", 10 | "patricia", 11 | "typeahead" 12 | ], 13 | "authors": [ 14 | "talyssonoc " 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/talyssonoc/jst9.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/talyssonoc/jst9/issues" 22 | }, 23 | "license": "MIT", 24 | "dependencies": { 25 | "axios": "^0.5.4" 26 | }, 27 | "ignore": [ 28 | "**/.*", 29 | "node_modules", 30 | "bower_components", 31 | "test", 32 | "tests", 33 | "example", 34 | "src" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /dist/jst9.js: -------------------------------------------------------------------------------- 1 | (function(root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define(['axios'], function(axios) { 4 | return (root.jsT9 = factory(axios)); 5 | }); 6 | } else if (typeof exports === 'object') { 7 | module.exports = factory(require('axios')); 8 | } else { 9 | root.jsT9 = factory(root.axios); 10 | } 11 | }(this, function(axios) { 12 | 'use strict'; 13 | /** 14 | * @constructor 15 | */ 16 | var jsT9 = function jsT9(_wordList, _config) { 17 | 'use strict'; 18 | 19 | this.config = { 20 | sort: function sort(wordA, wordB) { 21 | if (wordA.length < wordB.length) { 22 | return -1; 23 | } 24 | if (wordA.length > wordB.length) { 25 | return 1; 26 | } 27 | if (wordA <= wordB) { 28 | return -1; 29 | } 30 | else { 31 | return 1; 32 | } 33 | }, 34 | maxAmount: Infinity, 35 | caseSentitive: true, 36 | slackSearch: true 37 | }; 38 | 39 | // Extends the config options 40 | (function extend(destination, source) { 41 | for (var property in source) { 42 | destination[property] = source[property]; 43 | } 44 | return destination; 45 | })(this.config, _config); 46 | 47 | //The root of the tree 48 | this.root = { 49 | branches: [] 50 | }; 51 | 52 | this._onReadyCallbacks = []; 53 | 54 | this._getWordList(_wordList, function fillTree(wordList) { 55 | 56 | // For each word in the list 57 | for (var word in wordList) { 58 | this.addWord(wordList[word]); 59 | } 60 | 61 | this._isReady = true; 62 | 63 | setTimeout(function() { 64 | for(var fn in this._onReadyCallbacks) { 65 | this._onReadyCallbacks[fn].call(null); 66 | } 67 | }.bind(this), 0); 68 | 69 | }.bind(this)); 70 | }; 71 | 72 | jsT9.prototype = { 73 | constructor: jsT9, 74 | 75 | /** 76 | * Add a listener for when the tree is ready 77 | * @param {Function} fn Callback function 78 | */ 79 | ready: function ready(fn) { 80 | if(this._isReady) { 81 | return fn.call(null); 82 | } 83 | 84 | return this._onReadyCallbacks.push(fn); 85 | }, 86 | 87 | /** 88 | * Predict the words, given the initial word 89 | * @param {String} word The initial word 90 | * @return {Array} The array of Strings with the predicted words 91 | */ 92 | predict: function predict(word, amount) { 93 | 94 | if (!word) { 95 | return []; 96 | } 97 | 98 | amount = amount || this.config.maxAmount; 99 | 100 | var currentWord = false; 101 | 102 | var initialBranchResult = this._findInitialBranch(word); 103 | 104 | if(!initialBranchResult) { 105 | return []; 106 | } 107 | 108 | if (initialBranchResult.currentBranch.$ === true) { 109 | currentWord = initialBranchResult.baseWord; 110 | } 111 | 112 | var predictedList = this._exploreBranch(initialBranchResult.baseWord, initialBranchResult.currentBranch); 113 | 114 | if (currentWord) { 115 | predictedList.push(currentWord); 116 | } 117 | 118 | predictedList.sort(this.config.sort); 119 | 120 | return predictedList.slice(0, amount); 121 | }, 122 | 123 | _findInitialBranch: function _findInitialBranch(word) { 124 | var currentBranch = this.root; 125 | 126 | if(this.config.slackSearch) { 127 | return this._slackSearch(word, currentBranch); 128 | } 129 | 130 | return this._normalSearch(word, currentBranch); 131 | }, 132 | 133 | _normalSearch: function _normalSearch(word, currentBranch) { 134 | var baseWord = ''; 135 | var branchPrefix; 136 | var branch; 137 | var found; 138 | 139 | while(word.length) { 140 | found = false; 141 | for(branch in currentBranch.branches) { 142 | branchPrefix = currentBranch.branches[branch].prefix; 143 | 144 | if(word.length < branchPrefix.length) { 145 | 146 | if(branchPrefix.indexOf(word) === 0) { 147 | baseWord += branchPrefix; 148 | 149 | return { 150 | currentBranch: currentBranch.branches[branch], 151 | baseWord: baseWord 152 | }; 153 | } 154 | } 155 | else { 156 | 157 | if(word.indexOf(branchPrefix) === 0) { 158 | baseWord += branchPrefix; 159 | 160 | word = word.substring(branchPrefix.length); 161 | currentBranch = currentBranch.branches[branch]; 162 | found = true; 163 | break; 164 | } 165 | } 166 | } 167 | 168 | if(!found) { 169 | return false; 170 | } 171 | } 172 | 173 | return { 174 | currentBranch: currentBranch, 175 | baseWord: baseWord 176 | }; 177 | 178 | }, 179 | 180 | _slackSearch: function _slackSearch(word, currentBranch) { 181 | var baseWord = ''; 182 | var subString; 183 | var branch; 184 | var found; 185 | var i; 186 | 187 | while (word.length) { 188 | found = false; 189 | for (i = 0; i < word.length && !found; i++) { 190 | subString = word.substring(0, word.length - i); 191 | 192 | for (branch in currentBranch.branches) { 193 | if (currentBranch.branches[branch].prefix.indexOf(subString) === 0) { 194 | baseWord += currentBranch.branches[branch].prefix; 195 | 196 | if (currentBranch.branches[branch].prefix === subString) { 197 | word = word.substring(word.length - i); 198 | } 199 | //In cases where it begins with the substring, but doesn't end with it 200 | //so it should just start the search from there 201 | else { 202 | word = ''; 203 | } 204 | 205 | currentBranch = currentBranch.branches[branch]; 206 | found = true; 207 | break; 208 | } 209 | } 210 | } 211 | 212 | if (!found) { 213 | return false; 214 | } 215 | } 216 | 217 | return { 218 | currentBranch: currentBranch, 219 | baseWord: baseWord 220 | }; 221 | }, 222 | 223 | /** 224 | * Add a new word to the tree 225 | * @param {String} word Word to be added to the tree 226 | */ 227 | addWord: function addWord(word) { 228 | 229 | var branch = this.root; 230 | var stopSearchOnCurrentBranch = false; 231 | var newNode; 232 | 233 | while (!stopSearchOnCurrentBranch) { 234 | var wordContainsThePrefix = false; 235 | var isCase3 = false; 236 | var case2Result; 237 | 238 | //Looks for how branch it should follow 239 | for (var b in branch.branches) { 240 | 241 | //Case 1: current node prefix == `word` 242 | if(this._tryCase1(branch, word, b)) { 243 | return; 244 | } 245 | 246 | //Case 2: `word` begins with current node prefix to add 247 | //Cuts the word and goes to the next branch 248 | case2Result = this._tryCase2(branch, word, b); 249 | if(case2Result) { 250 | word = case2Result.word; 251 | branch = case2Result.branch; 252 | wordContainsThePrefix = true; 253 | break; 254 | } 255 | 256 | //Case 3: current node prefix begins with part of or whole `word` 257 | if(this._tryCase3(branch, word, b)) { 258 | isCase3 = stopSearchOnCurrentBranch = wordContainsThePrefix = true; 259 | break; 260 | } 261 | } 262 | 263 | //Case 4: current node prefix doesn't have intersection with `word` 264 | if(this._tryCase4(branch, word, wordContainsThePrefix)) { 265 | stopSearchOnCurrentBranch = true; 266 | } 267 | } 268 | }, 269 | 270 | _tryCase1: function _tryCase1(branch, word, b) { 271 | if (branch.branches[b].prefix === word) { 272 | branch.branches[b].$ = true; 273 | return true; 274 | } 275 | 276 | return false; 277 | }, 278 | 279 | _tryCase2: function _tryCase2(branch, word, b) { 280 | if (word.indexOf(branch.branches[b].prefix) === 0) { 281 | word = word.substring(branch.branches[b].prefix.length); 282 | branch = branch.branches[b]; 283 | 284 | return { 285 | branch: branch, 286 | word: word 287 | }; 288 | } 289 | 290 | return false; 291 | }, 292 | 293 | _tryCase3: function _tryCase3(branch, word, b) { 294 | var newNode; 295 | 296 | for (var i = 0; i <= word.length - 1; i++) { 297 | //Cuts the word starting from the end, 298 | //so it "merges" words that just begin equal between them 299 | var cutWord = word.substring(0, word.length - i); 300 | 301 | var restPrefix = word.substring(word.length - i); 302 | 303 | if (branch.branches[b].prefix.indexOf(cutWord) === 0) { 304 | //The new node where the word is cut 305 | newNode = { 306 | prefix: cutWord, 307 | branches: [], 308 | $: (restPrefix.length === 0) 309 | }; 310 | 311 | //The node that inherits the data from the old node 312 | //that was cut 313 | var inheritedNode = { 314 | prefix: branch.branches[b].prefix.substring(cutWord.length), 315 | branches: branch.branches[b].branches, 316 | $: branch.branches[b].$ 317 | }; 318 | 319 | branch.branches[b] = newNode; 320 | branch.branches[b].branches.push(inheritedNode); 321 | 322 | //If the prefixes only begin equal, creates the new node 323 | //with the rest of the prefix 324 | if (restPrefix.length > 0) { 325 | var restNode = { 326 | prefix: restPrefix, 327 | branches: [], 328 | $: true 329 | }; 330 | 331 | branch.branches[b].branches.push(restNode); 332 | } 333 | 334 | return true; 335 | } 336 | } 337 | }, 338 | 339 | _tryCase4: function _tryCase4(branch, word, wordContainsThePrefix) { 340 | var newNode; 341 | 342 | if (!wordContainsThePrefix) { 343 | newNode = { 344 | prefix: word, 345 | branches: [], 346 | $: true 347 | }; 348 | 349 | branch.branches.push(newNode); 350 | return true; 351 | } 352 | 353 | return false; 354 | }, 355 | 356 | /** 357 | * Looks for the words that contain the word passed as parameter 358 | * @param {String} baseWord The base to look for 359 | * @param {Object} currentBranch The begining branch 360 | * @return {Array} List of predicted words 361 | */ 362 | _exploreBranch: function _exploreBranch(baseWord, currentBranch) { 363 | var predictedList = []; 364 | 365 | for (var b in currentBranch.branches) { //For each branch forking from the branch 366 | var prefix = currentBranch.branches[b].prefix; //Get the leading character of the current branch 367 | 368 | if (currentBranch.branches[b].$ === true) { //If the current leaf ends a word, puts the word on the list 369 | predictedList.push(baseWord + prefix); 370 | } 371 | 372 | //Recursively calls the function, passing the forking branches as parameter 373 | var predictedWords = this._exploreBranch(baseWord + prefix, currentBranch.branches[b]); 374 | 375 | predictedList = predictedList.concat(predictedWords); 376 | 377 | } 378 | return predictedList; 379 | }, 380 | 381 | /** 382 | * Returns the word list base on config 383 | * @param {Array|String} 384 | * @param {Function} 385 | * @return {Array} 386 | */ 387 | _getWordList: function _getWordList(_wordList, callback) { 388 | if(Array.isArray(_wordList)) { 389 | 390 | callback(_wordList); 391 | 392 | } else if ((typeof _wordList) === 'string') { 393 | 394 | this._fetchWordList(_wordList, callback); 395 | 396 | } else { 397 | 398 | console.error((typeof _wordList) + ' variable is not supported as data source'); 399 | callback([]); 400 | 401 | } 402 | }, 403 | 404 | /** 405 | * Fetches the word list from an address 406 | * @param {String} path Path of a JSON file with an array called 'words' with the word list 407 | * @return {Array} Word list extracted from the given path 408 | */ 409 | _fetchWordList: function _fetchWordList(path, callback) { 410 | var words = []; 411 | 412 | axios.get(path) 413 | .then(function(response) { 414 | 415 | var jsonData = response.data; 416 | 417 | if(response.responseType === 'text') { 418 | jsonData = JSON.parse(jsonData); 419 | } 420 | 421 | if(Array.isArray(jsonData.words)) { 422 | words = jsonData.words; 423 | } 424 | 425 | callback(words); 426 | }.bind(this)) 427 | .catch(function(error) { 428 | callback(words); 429 | }.bind(this)); 430 | } 431 | }; 432 | 433 | return jsT9; 434 | })); 435 | -------------------------------------------------------------------------------- /dist/jst9.min.js: -------------------------------------------------------------------------------- 1 | !function(r,n){"function"==typeof define&&define.amd?define(["axios"],function(e){return r.jsT9=n(e)}):"object"==typeof exports?module.exports=n(require("axios")):r.jsT9=n(r.axios)}(this,function(r){"use strict";var n=function(r,n){this.config={sort:function(r,n){return r.lengthn.length?1:n>=r?-1:1},maxAmount:1/0,caseSentitive:!0,slackSearch:!0},function(r,n){for(var e in n)r[e]=n[e];return r}(this.config,n),this.root={branches:[]},this._onReadyCallbacks=[],this._getWordList(r,function(r){for(var n in r)this.addWord(r[n]);this._isReady=!0,setTimeout(function(){for(var r in this._onReadyCallbacks)this._onReadyCallbacks[r].call(null)}.bind(this),0)}.bind(this))};return n.prototype={constructor:n,ready:function(r){return this._isReady?r.call(null):this._onReadyCallbacks.push(r)},predict:function(r,n){if(!r)return[];n=n||this.config.maxAmount;var e=!1,t=this._findInitialBranch(r);if(!t)return[];t.currentBranch.$===!0&&(e=t.baseWord);var s=this._exploreBranch(t.baseWord,t.currentBranch);return e&&s.push(e),s.sort(this.config.sort),s.slice(0,n)},_findInitialBranch:function(r){var n=this.root;return this.config.slackSearch?this._slackSearch(r,n):this._normalSearch(r,n)},_normalSearch:function(r,n){for(var e,t,s,a="";r.length;){s=!1;for(t in n.branches)if(e=n.branches[t].prefix,r.length0){var c={prefix:i,branches:[],$:!0};r.branches[e].branches.push(c)}return!0}}},_tryCase4:function(r,n,e){var t;return e?!1:(t={prefix:n,branches:[],$:!0},r.branches.push(t),!0)},_exploreBranch:function(r,n){var e=[];for(var t in n.branches){var s=n.branches[t].prefix;n.branches[t].$===!0&&e.push(r+s);var a=this._exploreBranch(r+s,n.branches[t]);e=e.concat(a)}return e},_getWordList:function(r,n){Array.isArray(r)?n(r):"string"==typeof r?this._fetchWordList(r,n):(console.error(typeof r+" variable is not supported as data source"),n([]))},_fetchWordList:function(n,e){var t=[];r.get(n).then(function(r){var n=r.data;"text"===r.responseType&&(n=JSON.parse(n)),Array.isArray(n.words)&&(t=n.words),e(t)}.bind(this))["catch"](function(r){e(t)}.bind(this))}},n}); -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | jsT9 example 6 | 7 | 32 | 33 | 34 | 35 | 36 | 139 | 140 | 141 | 142 | 143 |
144 | 145 |
146 | 147 |
148 | 149 |
150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /example/words.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "Lorem", 4 | "ipsum", 5 | "dolor", 6 | "sit", 7 | "amet,", 8 | "consectetur", 9 | "adipisicing", 10 | "elit,", 11 | "sed", 12 | "do", 13 | "eiusmod", 14 | "tempor", 15 | "incididunt", 16 | "ut", 17 | "labore", 18 | "et", 19 | "dolore", 20 | "magna", 21 | "aliqua.", 22 | "Ut", 23 | "enim", 24 | "ad", 25 | "minim", 26 | "veniam,", 27 | "quis", 28 | "nostrud", 29 | "exercitation", 30 | "ullamco", 31 | "laboris", 32 | "nisi", 33 | "ut", 34 | "aliquip", 35 | "ex", 36 | "ea", 37 | "commodo", 38 | "consequat.", 39 | "Duis", 40 | "aute", 41 | "irure", 42 | "dolor", 43 | "in", 44 | "reprehenderit", 45 | "in", 46 | "voluptate", 47 | "velit", 48 | "esse", 49 | "cillum", 50 | "dolore", 51 | "eu", 52 | "fugiat", 53 | "nulla", 54 | "pariatur.", 55 | "Excepteur", 56 | "sint", 57 | "occaecat", 58 | "cupidatat", 59 | "non", 60 | "proident,", 61 | "sunt", 62 | "in", 63 | "culpa", 64 | "qui", 65 | "officia", 66 | "deserunt", 67 | "mollit", 68 | "anim", 69 | "id", 70 | "est", 71 | "laborum" 72 | ] 73 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var clean = require('gulp-clean'); 3 | var concat = require('gulp-concat'); 4 | var umd = require('gulp-umd'); 5 | var uglify = require('gulp-uglify'); 6 | var rename = require('gulp-rename'); 7 | var jshint = require('gulp-jshint'); 8 | var path = require('path'); 9 | var mocha = require('gulp-mocha'); 10 | 11 | gulp.task('clean', function () { 12 | return gulp.src('dist', { read: false }) 13 | .pipe(clean()); 14 | }); 15 | 16 | gulp.task('lint', function() { 17 | return gulp.src('src/jst9.js') 18 | .pipe(jshint()) 19 | .pipe(jshint.reporter('default', { verbose: true })); 20 | }); 21 | 22 | gulp.task('build', ['clean'], function() { 23 | return gulp.src([ 24 | 'src/jst9.js' 25 | ]) 26 | .pipe(concat('jst9.js')) 27 | .pipe(umd({ 28 | exports: function(file) { 29 | return 'jsT9'; 30 | }, 31 | 32 | namespace: function() { 33 | return 'jsT9'; 34 | }, 35 | 36 | dependencies: function() { 37 | return [ 38 | { 39 | name: 'axios', 40 | amd: 'axios', 41 | cjs: 'axios', 42 | global: 'axios', 43 | param: 'axios' 44 | } 45 | ]; 46 | }, 47 | 48 | template: path.join(__dirname, '/src/umdTemplate.js') 49 | })) 50 | .pipe(gulp.dest('dist')) 51 | .pipe(rename('jst9.min.js')) 52 | .pipe(uglify()) 53 | .pipe(gulp.dest('dist')); 54 | }); 55 | 56 | gulp.task('test', ['build'], function() { 57 | return gulp.src('test/*Test.js') 58 | .pipe(mocha({ 59 | globals: ['chai'], 60 | timeout: 6000, 61 | ignoreLeaks: false, 62 | ui: 'bdd', 63 | reporter: 'spec' 64 | })); 65 | }); 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jst9", 3 | "description": "A text-prediction JavaScript tool based on PATRICIA tree", 4 | "version": "0.4.0", 5 | "main": "dist/jst9", 6 | "keywords": [ 7 | "text prediction", 8 | "autocomplete", 9 | "tree", 10 | "patricia", 11 | "typeahead" 12 | ], 13 | "homepage": "https://github.com/talyssonoc/jst9", 14 | "author": { 15 | "name": "Talysson", 16 | "email": "talyssonoc@gmail.com" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/talyssonoc/jst9.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/talyssonoc/jst9/issues" 24 | }, 25 | "licenses": [ 26 | { 27 | "type": "MIT", 28 | "url": "https://github.com/talyssonoc/jst9/blob/master/LICENSE" 29 | } 30 | ], 31 | "scripts": { 32 | "test": "gulp test", 33 | "build": "gulp build" 34 | }, 35 | "dependencies": { 36 | "axios": "^0.5.4" 37 | }, 38 | "devDependencies": { 39 | "chai": "^2.3.0", 40 | "gulp": "^3.8.11", 41 | "gulp-clean": "^0.3.1", 42 | "gulp-concat": "^2.5.2", 43 | "gulp-jshint": "^1.10.0", 44 | "gulp-mocha": "^2.0.1", 45 | "gulp-rename": "^1.2.2", 46 | "gulp-uglify": "^1.2.0", 47 | "gulp-umd": "^0.1.3", 48 | "mocha": "^2.2.4" 49 | }, 50 | "engines": { 51 | "node": ">= 0.10.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/jst9.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @constructor 3 | */ 4 | var jsT9 = function jsT9(_wordList, _config) { 5 | 'use strict'; 6 | 7 | this.config = { 8 | sort: function sort(wordA, wordB) { 9 | if (wordA.length < wordB.length) { 10 | return -1; 11 | } 12 | if (wordA.length > wordB.length) { 13 | return 1; 14 | } 15 | if (wordA <= wordB) { 16 | return -1; 17 | } 18 | else { 19 | return 1; 20 | } 21 | }, 22 | maxAmount: Infinity, 23 | caseSentitive: true, 24 | slackSearch: true 25 | }; 26 | 27 | // Extends the config options 28 | (function extend(destination, source) { 29 | for (var property in source) { 30 | destination[property] = source[property]; 31 | } 32 | return destination; 33 | })(this.config, _config); 34 | 35 | //The root of the tree 36 | this.root = { 37 | branches: [] 38 | }; 39 | 40 | this._onReadyCallbacks = []; 41 | 42 | this._getWordList(_wordList, function fillTree(wordList) { 43 | 44 | // For each word in the list 45 | for (var word in wordList) { 46 | this.addWord(wordList[word]); 47 | } 48 | 49 | this._isReady = true; 50 | 51 | setTimeout(function() { 52 | for(var fn in this._onReadyCallbacks) { 53 | this._onReadyCallbacks[fn].call(null); 54 | } 55 | }.bind(this), 0); 56 | 57 | }.bind(this)); 58 | }; 59 | 60 | jsT9.prototype = { 61 | constructor: jsT9, 62 | 63 | /** 64 | * Add a listener for when the tree is ready 65 | * @param {Function} fn Callback function 66 | */ 67 | ready: function ready(fn) { 68 | if(this._isReady) { 69 | return fn.call(null); 70 | } 71 | 72 | return this._onReadyCallbacks.push(fn); 73 | }, 74 | 75 | /** 76 | * Predict the words, given the initial word 77 | * @param {String} word The initial word 78 | * @return {Array} The array of Strings with the predicted words 79 | */ 80 | predict: function predict(word, amount) { 81 | 82 | if (!word) { 83 | return []; 84 | } 85 | 86 | amount = amount || this.config.maxAmount; 87 | 88 | var currentWord = false; 89 | 90 | var initialBranchResult = this._findInitialBranch(word); 91 | 92 | if(!initialBranchResult) { 93 | return []; 94 | } 95 | 96 | if (initialBranchResult.currentBranch.$ === true) { 97 | currentWord = initialBranchResult.baseWord; 98 | } 99 | 100 | var predictedList = this._exploreBranch(initialBranchResult.baseWord, initialBranchResult.currentBranch); 101 | 102 | if (currentWord) { 103 | predictedList.push(currentWord); 104 | } 105 | 106 | predictedList.sort(this.config.sort); 107 | 108 | return predictedList.slice(0, amount); 109 | }, 110 | 111 | _findInitialBranch: function _findInitialBranch(word) { 112 | var currentBranch = this.root; 113 | 114 | if(this.config.slackSearch) { 115 | return this._slackSearch(word, currentBranch); 116 | } 117 | 118 | return this._normalSearch(word, currentBranch); 119 | }, 120 | 121 | _normalSearch: function _normalSearch(word, currentBranch) { 122 | var baseWord = ''; 123 | var branchPrefix; 124 | var branch; 125 | var found; 126 | 127 | while(word.length) { 128 | found = false; 129 | for(branch in currentBranch.branches) { 130 | branchPrefix = currentBranch.branches[branch].prefix; 131 | 132 | if(word.length < branchPrefix.length) { 133 | 134 | if(branchPrefix.indexOf(word) === 0) { 135 | baseWord += branchPrefix; 136 | 137 | return { 138 | currentBranch: currentBranch.branches[branch], 139 | baseWord: baseWord 140 | }; 141 | } 142 | } 143 | else { 144 | 145 | if(word.indexOf(branchPrefix) === 0) { 146 | baseWord += branchPrefix; 147 | 148 | word = word.substring(branchPrefix.length); 149 | currentBranch = currentBranch.branches[branch]; 150 | found = true; 151 | break; 152 | } 153 | } 154 | } 155 | 156 | if(!found) { 157 | return false; 158 | } 159 | } 160 | 161 | return { 162 | currentBranch: currentBranch, 163 | baseWord: baseWord 164 | }; 165 | 166 | }, 167 | 168 | _slackSearch: function _slackSearch(word, currentBranch) { 169 | var baseWord = ''; 170 | var subString; 171 | var branch; 172 | var found; 173 | var i; 174 | 175 | while (word.length) { 176 | found = false; 177 | for (i = 0; i < word.length && !found; i++) { 178 | subString = word.substring(0, word.length - i); 179 | 180 | for (branch in currentBranch.branches) { 181 | if (currentBranch.branches[branch].prefix.indexOf(subString) === 0) { 182 | baseWord += currentBranch.branches[branch].prefix; 183 | 184 | if (currentBranch.branches[branch].prefix === subString) { 185 | word = word.substring(word.length - i); 186 | } 187 | //In cases where it begins with the substring, but doesn't end with it 188 | //so it should just start the search from there 189 | else { 190 | word = ''; 191 | } 192 | 193 | currentBranch = currentBranch.branches[branch]; 194 | found = true; 195 | break; 196 | } 197 | } 198 | } 199 | 200 | if (!found) { 201 | return false; 202 | } 203 | } 204 | 205 | return { 206 | currentBranch: currentBranch, 207 | baseWord: baseWord 208 | }; 209 | }, 210 | 211 | /** 212 | * Add a new word to the tree 213 | * @param {String} word Word to be added to the tree 214 | */ 215 | addWord: function addWord(word) { 216 | 217 | var branch = this.root; 218 | var stopSearchOnCurrentBranch = false; 219 | var newNode; 220 | 221 | while (!stopSearchOnCurrentBranch) { 222 | var wordContainsThePrefix = false; 223 | var isCase3 = false; 224 | var case2Result; 225 | 226 | //Looks for how branch it should follow 227 | for (var b in branch.branches) { 228 | 229 | //Case 1: current node prefix == `word` 230 | if(this._tryCase1(branch, word, b)) { 231 | return; 232 | } 233 | 234 | //Case 2: `word` begins with current node prefix to add 235 | //Cuts the word and goes to the next branch 236 | case2Result = this._tryCase2(branch, word, b); 237 | if(case2Result) { 238 | word = case2Result.word; 239 | branch = case2Result.branch; 240 | wordContainsThePrefix = true; 241 | break; 242 | } 243 | 244 | //Case 3: current node prefix begins with part of or whole `word` 245 | if(this._tryCase3(branch, word, b)) { 246 | isCase3 = stopSearchOnCurrentBranch = wordContainsThePrefix = true; 247 | break; 248 | } 249 | } 250 | 251 | //Case 4: current node prefix doesn't have intersection with `word` 252 | if(this._tryCase4(branch, word, wordContainsThePrefix)) { 253 | stopSearchOnCurrentBranch = true; 254 | } 255 | } 256 | }, 257 | 258 | _tryCase1: function _tryCase1(branch, word, b) { 259 | if (branch.branches[b].prefix === word) { 260 | branch.branches[b].$ = true; 261 | return true; 262 | } 263 | 264 | return false; 265 | }, 266 | 267 | _tryCase2: function _tryCase2(branch, word, b) { 268 | if (word.indexOf(branch.branches[b].prefix) === 0) { 269 | word = word.substring(branch.branches[b].prefix.length); 270 | branch = branch.branches[b]; 271 | 272 | return { 273 | branch: branch, 274 | word: word 275 | }; 276 | } 277 | 278 | return false; 279 | }, 280 | 281 | _tryCase3: function _tryCase3(branch, word, b) { 282 | var newNode; 283 | 284 | for (var i = 0; i <= word.length - 1; i++) { 285 | //Cuts the word starting from the end, 286 | //so it "merges" words that just begin equal between them 287 | var cutWord = word.substring(0, word.length - i); 288 | 289 | var restPrefix = word.substring(word.length - i); 290 | 291 | if (branch.branches[b].prefix.indexOf(cutWord) === 0) { 292 | //The new node where the word is cut 293 | newNode = { 294 | prefix: cutWord, 295 | branches: [], 296 | $: (restPrefix.length === 0) 297 | }; 298 | 299 | //The node that inherits the data from the old node 300 | //that was cut 301 | var inheritedNode = { 302 | prefix: branch.branches[b].prefix.substring(cutWord.length), 303 | branches: branch.branches[b].branches, 304 | $: branch.branches[b].$ 305 | }; 306 | 307 | branch.branches[b] = newNode; 308 | branch.branches[b].branches.push(inheritedNode); 309 | 310 | //If the prefixes only begin equal, creates the new node 311 | //with the rest of the prefix 312 | if (restPrefix.length > 0) { 313 | var restNode = { 314 | prefix: restPrefix, 315 | branches: [], 316 | $: true 317 | }; 318 | 319 | branch.branches[b].branches.push(restNode); 320 | } 321 | 322 | return true; 323 | } 324 | } 325 | }, 326 | 327 | _tryCase4: function _tryCase4(branch, word, wordContainsThePrefix) { 328 | var newNode; 329 | 330 | if (!wordContainsThePrefix) { 331 | newNode = { 332 | prefix: word, 333 | branches: [], 334 | $: true 335 | }; 336 | 337 | branch.branches.push(newNode); 338 | return true; 339 | } 340 | 341 | return false; 342 | }, 343 | 344 | /** 345 | * Looks for the words that contain the word passed as parameter 346 | * @param {String} baseWord The base to look for 347 | * @param {Object} currentBranch The begining branch 348 | * @return {Array} List of predicted words 349 | */ 350 | _exploreBranch: function _exploreBranch(baseWord, currentBranch) { 351 | var predictedList = []; 352 | 353 | for (var b in currentBranch.branches) { //For each branch forking from the branch 354 | var prefix = currentBranch.branches[b].prefix; //Get the leading character of the current branch 355 | 356 | if (currentBranch.branches[b].$ === true) { //If the current leaf ends a word, puts the word on the list 357 | predictedList.push(baseWord + prefix); 358 | } 359 | 360 | //Recursively calls the function, passing the forking branches as parameter 361 | var predictedWords = this._exploreBranch(baseWord + prefix, currentBranch.branches[b]); 362 | 363 | predictedList = predictedList.concat(predictedWords); 364 | 365 | } 366 | return predictedList; 367 | }, 368 | 369 | /** 370 | * Returns the word list base on config 371 | * @param {Array|String} 372 | * @param {Function} 373 | * @return {Array} 374 | */ 375 | _getWordList: function _getWordList(_wordList, callback) { 376 | if(Array.isArray(_wordList)) { 377 | 378 | callback(_wordList); 379 | 380 | } else if ((typeof _wordList) === 'string') { 381 | 382 | this._fetchWordList(_wordList, callback); 383 | 384 | } else { 385 | 386 | console.error((typeof _wordList) + ' variable is not supported as data source'); 387 | callback([]); 388 | 389 | } 390 | }, 391 | 392 | /** 393 | * Fetches the word list from an address 394 | * @param {String} path Path of a JSON file with an array called 'words' with the word list 395 | * @return {Array} Word list extracted from the given path 396 | */ 397 | _fetchWordList: function _fetchWordList(path, callback) { 398 | var words = []; 399 | 400 | axios.get(path) 401 | .then(function(response) { 402 | 403 | var jsonData = response.data; 404 | 405 | if(response.responseType === 'text') { 406 | jsonData = JSON.parse(jsonData); 407 | } 408 | 409 | if(Array.isArray(jsonData.words)) { 410 | words = jsonData.words; 411 | } 412 | 413 | callback(words); 414 | }.bind(this)) 415 | .catch(function(error) { 416 | callback(words); 417 | }.bind(this)); 418 | } 419 | }; 420 | -------------------------------------------------------------------------------- /src/umdTemplate.js: -------------------------------------------------------------------------------- 1 | (function(root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define(<%= amd %>, function(<%= param %>) { 4 | return (root.<%= namespace %> = factory(<%= param %>)); 5 | }); 6 | } else if (typeof exports === 'object') { 7 | module.exports = factory(<%= cjs %>); 8 | } else { 9 | root.<%= namespace %> = factory(<%= global %>); 10 | } 11 | }(this, function(<%= param %>) { 12 | 'use strict'; 13 | <%= contents %> 14 | return <%= exports %>; 15 | })); 16 | -------------------------------------------------------------------------------- /test/asyncTest.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jst9 3 | * https://github.com/talyssonoc/jst9 4 | * 5 | * Copyright (c) 2013 Talysson 6 | * Licensed under the MIT license. 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var chai = require('chai'); 12 | var expect = chai.expect; 13 | 14 | var jsT9 = require('../dist/jst9'); 15 | 16 | var server = require('./fixtures/server'); 17 | 18 | var openServer = function openServer(cb) { 19 | server.listen(1337, cb); 20 | }; 21 | 22 | var path = 'http://localhost:1337/'; 23 | 24 | describe('Async tests loading JSON data from server', function() { 25 | it('Predict "L"', function(done) { 26 | 27 | openServer(function() { 28 | var tree = new jsT9(path); 29 | 30 | tree.ready(function() { 31 | expect(tree.predict('L')).to.eql(['Lorem']); 32 | server.close(done); 33 | }); 34 | }); 35 | 36 | }); 37 | 38 | it('Predict "ad"', function(done) { 39 | openServer(function() { 40 | var tree = new jsT9(path); 41 | 42 | tree.ready(function() { 43 | expect(tree.predict('ad')).to.eql(['ad', 'adipisicing']); 44 | server.close(done); 45 | }); 46 | }); 47 | }); 48 | 49 | it('Predict "l"', function(done) { 50 | openServer(function() { 51 | var tree = new jsT9(path); 52 | 53 | tree.ready(function() { 54 | expect(tree.predict('l')).to.eql(['labore', 'laboris', 'laborum']); 55 | server.close(done); 56 | }); 57 | }); 58 | }); 59 | 60 | it('Add new world and find it', function(done) { 61 | openServer(function() { 62 | var tree = new jsT9(path); 63 | 64 | tree.ready(function() { 65 | tree.addWord('testing jsT9'); 66 | expect(tree.predict('testing')).to.eql(['testing jsT9']); 67 | server.close(done); 68 | }); 69 | }); 70 | }); 71 | 72 | it('Should get an empty array if no matches are found', function(done) { 73 | openServer(function() { 74 | var tree = new jsT9(path); 75 | 76 | tree.ready(function() { 77 | expect(tree.predict('Yep, it is not there')).to.eql([]); 78 | server.close(done); 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/fixtures/lorem_tree.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Lorem", 3 | "ipsum", 4 | "dolor", 5 | "sit", 6 | "amet,", 7 | "consectetur", 8 | "adipisicing", 9 | "elit,", 10 | "sed", 11 | "do", 12 | "eiusmod", 13 | "tempor", 14 | "incididunt", 15 | "ut", 16 | "labore", 17 | "et", 18 | "dolore", 19 | "magna", 20 | "aliqua.", 21 | "Ut", 22 | "enim", 23 | "ad", 24 | "minim", 25 | "veniam,", 26 | "quis", 27 | "nostrud", 28 | "exercitation", 29 | "ullamco", 30 | "laboris", 31 | "nisi", 32 | "ut", 33 | "aliquip", 34 | "ex", 35 | "ea", 36 | "commodo", 37 | "consequat.", 38 | "Duis", 39 | "aute", 40 | "irure", 41 | "dolor", 42 | "in", 43 | "reprehenderit", 44 | "in", 45 | "voluptate", 46 | "velit", 47 | "esse", 48 | "cillum", 49 | "dolore", 50 | "eu", 51 | "fugiat", 52 | "nulla", 53 | "pariatur.", 54 | "Excepteur", 55 | "sint", 56 | "occaecat", 57 | "cupidatat", 58 | "non", 59 | "proident,", 60 | "sunt", 61 | "in", 62 | "culpa", 63 | "qui", 64 | "officia", 65 | "deserunt", 66 | "mollit", 67 | "anim", 68 | "id", 69 | "est", 70 | "laborum" 71 | ] 72 | -------------------------------------------------------------------------------- /test/fixtures/server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | 3 | var lorem = require('./lorem_tree'); 4 | 5 | var server = http.createServer(function (req, res) { 6 | res.writeHead(200, {'Content-Type': 'application/json'}); 7 | res.end(JSON.stringify({ 8 | words: lorem 9 | })); 10 | }); 11 | 12 | module.exports = server; 13 | -------------------------------------------------------------------------------- /test/normalSearchTest.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jst9 3 | * https://github.com/talyssonoc/jst9 4 | * 5 | * Copyright (c) 2013 Talysson 6 | * Licensed under the MIT license. 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var chai = require('chai'); 12 | var expect = chai.expect; 13 | 14 | var jsT9 = require('../dist/jst9'); 15 | 16 | var lorem = require('./fixtures/lorem_tree'); 17 | 18 | var tree = new jsT9(lorem, { 19 | slackSearch: false 20 | }); 21 | 22 | describe('Normal search tests', function(){ 23 | it('Predict "L"', function() { 24 | expect(tree.predict('L')).to.eql(['Lorem']); 25 | }); 26 | 27 | it('Predict "Lx" and don\'t find matches', function() { 28 | expect(tree.predict('Lx')).to.eql([]); 29 | }); 30 | 31 | it('Predict "ad"', function() { 32 | expect(tree.predict('ad')).to.eql(['ad', 'adipisicing']); 33 | }); 34 | 35 | it('Predict "l"', function() { 36 | expect(tree.predict('l')).to.eql(['labore', 'laboris', 'laborum']); 37 | }); 38 | 39 | it('Add new world and find it', function() { 40 | tree.addWord('testing jsT9'); 41 | expect(tree.predict('testing')).to.eql(['testing jsT9']); 42 | }); 43 | 44 | it('Should get an empty array if no matches are found', function() { 45 | expect(tree.predict('Yep, it is not there')).to.eql([]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/slackSearchTest.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jst9 3 | * https://github.com/talyssonoc/jst9 4 | * 5 | * Copyright (c) 2013 Talysson 6 | * Licensed under the MIT license. 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var chai = require('chai'); 12 | var expect = chai.expect; 13 | 14 | var jsT9 = require('../dist/jst9'); 15 | 16 | var lorem = require('./fixtures/lorem_tree'); 17 | 18 | var tree = new jsT9(lorem); 19 | 20 | describe('Slack search tests', function(){ 21 | it('Predict "L"', function() { 22 | expect(tree.predict('L')).to.eql(['Lorem']); 23 | }); 24 | 25 | it('Predict "Lx" and find matches for "L"', function() { 26 | expect(tree.predict('Lx')).to.eql(['Lorem']); 27 | }); 28 | 29 | it('Predict "ad"', function() { 30 | expect(tree.predict('ad')).to.eql(['ad', 'adipisicing']); 31 | }); 32 | 33 | it('Predict "l"', function() { 34 | expect(tree.predict('l')).to.eql(['labore', 'laboris', 'laborum']); 35 | }); 36 | 37 | it('Add new world and find it', function() { 38 | tree.addWord('testing jsT9'); 39 | expect(tree.predict('testing')).to.eql(['testing jsT9']); 40 | }); 41 | 42 | it('Should get an empty array if no matches are found', function() { 43 | expect(tree.predict('Yep, it is not there')).to.eql([]); 44 | }); 45 | }); 46 | --------------------------------------------------------------------------------