├── .gitignore ├── LICENSE ├── LICENSE-ln ├── README.md ├── app └── jobs │ └── regular │ ├── update_elasticsearch_post.rb │ ├── update_elasticsearch_tag.rb │ ├── update_elasticsearch_topic.rb │ └── update_elasticsearch_user.rb ├── assets ├── javascripts │ └── initializers │ │ ├── discourse-autocomplete.js.es6 │ │ └── discourse-elasticsearch.js.es6 ├── lib │ └── typehead.bundle.js └── stylesheets │ ├── elasticsearch-base.scss │ ├── elasticsearch-layout.scss │ └── variables.scss ├── config ├── locales │ ├── server.en.yml │ └── server.zh_CN.yml └── settings.yml ├── lib ├── discourse_elasticsearch │ └── elasticsearch_helper.rb └── tasks │ └── discourse_elasticsearch.rake ├── plugin.rb ├── spec └── requests │ └── actions_controller_spec.rb └── test └── javascripts └── acceptance └── discourse-elasticsearch-test.js.es6 /.gitignore: -------------------------------------------------------------------------------- 1 | gems/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 imMMX. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /LICENSE-ln: -------------------------------------------------------------------------------- 1 | LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discourse-elasticsearch 2 | 3 | discourse-elasticsearch is a plugin for elasticsearch forked from [discourse-algolia](https://github.com/discourse/discourse-algolia) 4 | 5 | ## Installation 6 | 7 | Follow [Install a Plugin](https://meta.discourse.org/t/install-a-plugin/19157) 8 | 9 | Installation 10 | Add this repository's git clone url to your container's app.yml file, at the bottom of the cmd section: 11 | 12 | ```yml 13 | hooks: 14 | after_code: 15 | - exec: 16 | cd: $home/plugins 17 | cmd: 18 | - mkdir -p plugins 19 | - git clone https://github.com/imMMX/discourse-elasticsearch.git 20 | ``` 21 | 22 | 23 | Rebuild your container: 24 | 25 | ``` 26 | cd /var/discourse 27 | git pull 28 | ./launcher rebuild app 29 | ``` 30 | 31 | Then you need install a elasticsearch(version 7.2.0) server, here's a docker example: 32 | 33 | ``` 34 | docker run -p 5601:5601 -p 9200:9200 -p 5044:5044 -it --name elk --restart=always -d elk:7.2.0 35 | ``` 36 | 37 | 38 | Install elk chinese plugin: 39 | 40 | ``` 41 | docker exec -it elk bash 42 | ./bin/elasticsearch-plugin install -b https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.2.0/elasticsearch-analysis-ik-7.2.0.zip 43 | ``` 44 | 45 | ## Configuration 46 | 47 | Once you've installed the plugin and restarted your Discourse, you will see a new plugin available in your admin configuration. Click the Settings button next to the discourse-elasticsearch plugin. 48 | 49 | ## Initial indexing 50 | 51 | You can now index all of your forum's content. Run the following rake task in your Discourse directory: 52 | 53 | ``` 54 | ./launcher enter app 55 | su discourse 56 | LOAD_PLUGINS=1 bundle exec rails elasticsearch:initialize 57 | ``` 58 | This will create and configure three indices - discourse-users, discourse-posts, and discourse-tags - and then populate them by loading data from your database and sending it to Elasticsearch. 59 | 60 | ## Rake tasks 61 | 62 | ``` 63 | rake elasticsearch:configure # configure elasticsearch index settings 64 | rake elasticsearch:initialize # configure indices and upload data 65 | rake elasticsearch:reindex # reindex everything to elasticsearch 66 | rake elasticsearch:reindex_posts # reindex posts in elasticsearch 67 | rake elasticsearch:reindex_tags # reindex tags in elasticsearch 68 | rake elasticsearch:reindex_users # reindex users in elasticsearch 69 | ``` 70 | 71 | ## Usage 72 | 73 | ## Feedback 74 | 75 | If you have issues or suggestions for the plugin, please open an issue. 76 | -------------------------------------------------------------------------------- /app/jobs/regular/update_elasticsearch_post.rb: -------------------------------------------------------------------------------- 1 | module Jobs 2 | class UpdateElasticsearchPost < Jobs::Base 3 | def execute(args) 4 | DiscourseElasticsearch::ElasticsearchHelper.index_post(args[:post_id], args[:discourse_event]) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/jobs/regular/update_elasticsearch_tag.rb: -------------------------------------------------------------------------------- 1 | module Jobs 2 | class UpdateElasticsearchTag < Jobs::Base 3 | def execute(args) 4 | DiscourseElasticsearch::ElasticsearchHelper.index_tags(args[:tags], args[:discourse_event]) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/jobs/regular/update_elasticsearch_topic.rb: -------------------------------------------------------------------------------- 1 | module Jobs 2 | class UpdateElasticsearchTopic < Jobs::Base 3 | def execute(args) 4 | DiscourseElasticsearch::ElasticsearchHelper.index_topic(args[:topic_id], args[:discourse_event]) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/jobs/regular/update_elasticsearch_user.rb: -------------------------------------------------------------------------------- 1 | module Jobs 2 | class UpdateElasticsearchUser < Jobs::Base 3 | def execute(args) 4 | DiscourseElasticsearch::ElasticsearchHelper.index_user(args[:user_id], args[:discourse_event]) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /assets/javascripts/initializers/discourse-autocomplete.js.es6: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | name: "discourse-autocomplete", 4 | initialize() {}, 5 | _initialize(options) { 6 | //getlogin status; if visitor loading image place 7 | var currentUser = Discourse.User.current(); 8 | if (!currentUser) { 9 | $('.Typeahead-spinner').css("left","390px"); 10 | } 11 | // ajax function 12 | var callES = function(ESUrl,json){ 13 | //Get text from the input field 14 | var data = json, 15 | formatType = "json", 16 | type = "post", 17 | contentType = "application/json; charset=utf-8"; 18 | if(type === "post" && formatType === "json"){ 19 | data = JSON.stringify(data); 20 | } 21 | if(formatType === "json"){ 22 | contentType="application/json"; 23 | } 24 | return $.ajax({ 25 | dataType : "json", 26 | async : true, 27 | type:type, 28 | cache: false, 29 | url:ESUrl, 30 | data:data, 31 | contentType:contentType, 32 | timeout:30000 33 | }) 34 | } 35 | 36 | 37 | //discourse-posts source org 38 | var discoursePosts = function(query, processSync, processAsync){ 39 | var url = options.elasticsearch_address + "/discourse-posts/_search", 40 | text = $('#search-box').val(), 41 | json = { 42 | "query":{ 43 | "multi_match": 44 | {"query":text, 45 | "fields":["content","topic.title"], 46 | "type":"best_fields" 47 | } 48 | } 49 | }; 50 | 51 | return callES(url,json).then(function(rs){ 52 | var dfd = $.Deferred(); 53 | var results = $.map([0], function() { 54 | 55 | //Parse the results and return them 56 | var resultsData = rs.hits, 57 | resultsLength = Object.keys(resultsData.hits).length, 58 | datum = []; 59 | 60 | for (var i = 0; i < resultsLength; i++) { 61 | var resultsArray = resultsData.hits[i]._source, 62 | topic_name = resultsArray.topic.title, 63 | topic_view = resultsArray.topic.views, 64 | topic_url = resultsArray.topic.url, 65 | category = resultsArray.category.name, 66 | category_color = resultsArray.category.color, 67 | category_url = resultsArray.category.url, 68 | author = resultsArray.user.username, 69 | author_url = resultsArray.user.url, 70 | pre = resultsArray.content; 71 | 72 | datum.push({ 73 | // post 74 | post_topic_name: topic_name, 75 | post_topic_view: topic_view, 76 | url: topic_url, 77 | post_category: category, 78 | post_category_color: category_color, 79 | post_category_url: category_url, 80 | post_author: author, 81 | post_author_url: author_url, 82 | post_pre: pre 83 | 84 | }); 85 | 86 | } 87 | return datum; 88 | }); 89 | processAsync(results); 90 | 91 | return dfd.promise(); 92 | },function(){ 93 | alert("网络异常"); 94 | }); 95 | } 96 | 97 | 98 | //discourse-users source org 99 | var discourseUsers = function(query, processSync, processAsync){ 100 | var url = options.elasticsearch_address + "/discourse-users/_search", 101 | text = $('#search-box').val(), 102 | json = { 103 | "query":{ 104 | "multi_match": 105 | {"query":text, 106 | "fields":["url","name","username"], 107 | "type":"best_fields" 108 | } 109 | } 110 | }; 111 | 112 | return callES(url,json).then(function(rs){ 113 | var dfd = $.Deferred(); 114 | var results = $.map([0], function() { 115 | 116 | //Parse the results and return them 117 | var resultsData = rs.hits, 118 | resultsLength = Object.keys(resultsData.hits).length, 119 | datum = []; 120 | 121 | for (var i = 0; i < resultsLength; i++) { 122 | var resultsArray = resultsData.hits[i]._source, 123 | user_avatar = resultsArray.avatar_template.replace("\{size}", 50); 124 | 125 | datum.push({ 126 | // user 127 | user_avatar_template: user_avatar, 128 | user_username: resultsArray.username, 129 | user_likes_received: resultsArray.likes_received, 130 | url: resultsArray.url 131 | }); 132 | 133 | } 134 | return datum; 135 | }); 136 | processAsync(results); 137 | 138 | return dfd.promise(); 139 | },function(){ 140 | alert("网络异常"); 141 | }); 142 | } 143 | 144 | //discourse-tags source org 145 | var discourseTags = function(query, processSync, processAsync){ 146 | var url = options.elasticsearch_address + "/discourse-tags/_search", 147 | text = $('#search-box').val(), 148 | json = { 149 | "query":{ 150 | "multi_match": 151 | {"query":text, 152 | "fields":["url","name"], 153 | "type":"best_fields" 154 | } 155 | } 156 | }; 157 | 158 | return callES(url,json).then(function(rs){ 159 | var dfd = $.Deferred(); 160 | var results = $.map([0], function() { 161 | 162 | //Parse the results and return them 163 | var resultsData = rs.hits, 164 | resultsLength = Object.keys(resultsData.hits).length, 165 | datum = []; 166 | 167 | for (var i = 0; i < resultsLength; i++) { 168 | var resultsArray = resultsData.hits[i]._source; 169 | datum.push({ 170 | // tag 171 | tag_name: resultsArray.name, 172 | tag_topic_count: resultsArray.topic_count, 173 | url:resultsArray.url 174 | }); 175 | 176 | } 177 | return datum; 178 | }); 179 | processAsync(results); 180 | 181 | return dfd.promise(); 182 | },function(){ 183 | alert("网络异常"); 184 | }); 185 | } 186 | 187 | 188 | 189 | 190 | 191 | $('#search-box').typeahead({ 192 | highlight: true, 193 | minLength: 1 194 | }, 195 | { 196 | name: 'posts', 197 | displayKey: 'value', 198 | source: discoursePosts, 199 | async: true, 200 | templates: { 201 | empty: `
No matching posts.
`, 202 | footer: [ 203 | '
', 204 | '更多...', 205 | '
' 206 | ].join('\n'), 207 | suggestion: function(value) { 208 | if (value.post_topic_name != undefined) { 209 | return ` 210 |
211 |
212 |
213 | 214 | ${value.post_topic_name} 215 | 216 | 217 | ${value.post_topic_view} 218 | 219 |
220 | 228 |
229 | ${value.post_author}:${value.post_pre} 230 |
231 |
232 |
` 233 | }else{ 234 | return '' 235 | 236 | } 237 | } 238 | } 239 | },{ 240 | name: 'users-tags', 241 | displayKey: 'value', 242 | source: discourseUsers, 243 | async: true, 244 | templates: { 245 | empty: "", 246 | suggestion: function(value) { 247 | if (value.user_username != undefined){ 248 | return ` 249 | ` 263 | }else{ 264 | return '' 265 | } 266 | } 267 | } 268 | 269 | },{ 270 | name: 'emp', 271 | displayKey: 'value', 272 | source: discourseTags, 273 | async: true, 274 | templates: { 275 | empty: "", 276 | suggestion: function(value) { 277 | if (value.tag_name != undefined) { 278 | return ` 279 | ` 287 | }else{ 288 | return '' 289 | } 290 | } 291 | } 292 | 293 | }).on('typeahead:selected', function(event, datum) { 294 | window.location = datum.url; 295 | }).on('typeahead:asyncrequest', function() { 296 | $('.Typeahead-spinner').show(); 297 | }).on('typeahead:asynccancel typeahead:asyncreceive', function() { 298 | $('.Typeahead-spinner').hide(); 299 | }).on({ 300 | 'typeahead:render': function (event, datum) { 301 | console.log(Array.prototype.slice.call(arguments, 1)[0]); 302 | 303 | if (datum.length > 0 && Array.prototype.slice.call(arguments, 1)[2] == 'emp') { 304 | $('.tt-dataset-users-tags').delay(500).queue(function (next) { 305 | for (var _i = 0; _i < datum.length; _i++) { 306 | var tags = datum[_i]; 307 | var $tags = $(''); 308 | // Append to user-tag list. 309 | $(this).append($tags); 310 | next(); 311 | } 312 | }); 313 | } 314 | $('.tt-dataset-emp').empty(); 315 | } 316 | }); 317 | 318 | 319 | $("#search-box").on('focus', function (event) { 320 | $(this).select(); 321 | }); 322 | } 323 | } 324 | 325 | -------------------------------------------------------------------------------- /assets/javascripts/initializers/discourse-elasticsearch.js.es6: -------------------------------------------------------------------------------- 1 | import { h } from 'virtual-dom'; 2 | import { on } from 'ember-addons/ember-computed-decorators'; 3 | import DiscourseURL from 'discourse/lib/url'; 4 | import { withPluginApi } from 'discourse/lib/plugin-api'; 5 | import discourseAutocomplete from './discourse-autocomplete'; 6 | 7 | function elasticsearch(api){ 8 | const container = api.container; 9 | const siteSettings = container.lookup("site-settings:main"); 10 | api.modifyClass('component:site-header', { 11 | @on("didInsertElement") 12 | initializeElk() { 13 | this._super(); 14 | var elasticsearch_address = this.siteSettings.elasticsearch_server_ip + ":" + this.siteSettings.elasticsearch_server_port; 15 | if (this.siteSettings.elasticsearch_enabled) { 16 | $("body").addClass("elasticsearch-enabled"); 17 | if (!this.siteSettings.elasticsearch_server_port) { 18 | elasticsearch_address = this.siteSettings.elasticsearch_server_ip; 19 | } 20 | setTimeout(() => { 21 | discourseAutocomplete._initialize({ 22 | elasticsearch_address: elasticsearch_address 23 | }); 24 | }, 100); 25 | } 26 | } 27 | }); 28 | 29 | api.createWidget('es', { 30 | tagName: 'li.es-holder', 31 | html() { 32 | return [ 33 | h('form', { 34 | action: '/search', 35 | method: 'GET' 36 | }, [ 37 | h('input.es-input#search-box', { 38 | name: "q", 39 | placeholder: "Search the forum...", 40 | autocomplete: "off" 41 | }), 42 | h('img.Typeahead-spinner',{ 43 | src: "https://hugelolcdn.com/comments/1225799.gif" 44 | }) 45 | ]) 46 | ]; 47 | } 48 | }); 49 | 50 | api.decorateWidget('header-icons:before', function(helper) { 51 | if (helper.widget.siteSettings.elasticsearch_enabled) { 52 | return helper.attach('es'); 53 | } 54 | }); 55 | } 56 | 57 | export default { 58 | name : "discourse-elasticsearch", 59 | initialize(container) { 60 | withPluginApi('0.8.8', api => elasticsearch(api, container)); 61 | 62 | } 63 | } -------------------------------------------------------------------------------- /assets/lib/typehead.bundle.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * typeahead.js 1.2.0 3 | * https://github.com/twitter/typeahead.js 4 | * Copyright 2013-2017 Twitter, Inc. and other contributors; Licensed MIT 5 | */ 6 | 7 | (function(root, factory) { 8 | if (typeof define === "function" && define.amd) { 9 | define([ "jquery" ], function(a0) { 10 | return root["Bloodhound"] = factory(a0); 11 | }); 12 | } else if (typeof exports === "object") { 13 | module.exports = factory(require("jquery")); 14 | } else { 15 | root["Bloodhound"] = factory(root["jQuery"]); 16 | } 17 | })(this, function($) { 18 | var _ = function() { 19 | "use strict"; 20 | return { 21 | isMsie: function() { 22 | return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; 23 | }, 24 | isBlankString: function(str) { 25 | return !str || /^\s*$/.test(str); 26 | }, 27 | escapeRegExChars: function(str) { 28 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); 29 | }, 30 | isString: function(obj) { 31 | return typeof obj === "string"; 32 | }, 33 | isNumber: function(obj) { 34 | return typeof obj === "number"; 35 | }, 36 | isArray: $.isArray, 37 | isFunction: $.isFunction, 38 | isObject: $.isPlainObject, 39 | isUndefined: function(obj) { 40 | return typeof obj === "undefined"; 41 | }, 42 | isElement: function(obj) { 43 | return !!(obj && obj.nodeType === 1); 44 | }, 45 | isJQuery: function(obj) { 46 | return obj instanceof $; 47 | }, 48 | toStr: function toStr(s) { 49 | return _.isUndefined(s) || s === null ? "" : s + ""; 50 | }, 51 | bind: $.proxy, 52 | each: function(collection, cb) { 53 | $.each(collection, reverseArgs); 54 | function reverseArgs(index, value) { 55 | return cb(value, index); 56 | } 57 | }, 58 | map: $.map, 59 | filter: $.grep, 60 | every: function(obj, test) { 61 | var result = true; 62 | if (!obj) { 63 | return result; 64 | } 65 | $.each(obj, function(key, val) { 66 | if (!(result = test.call(null, val, key, obj))) { 67 | return false; 68 | } 69 | }); 70 | return !!result; 71 | }, 72 | some: function(obj, test) { 73 | var result = false; 74 | if (!obj) { 75 | return result; 76 | } 77 | $.each(obj, function(key, val) { 78 | if (result = test.call(null, val, key, obj)) { 79 | return false; 80 | } 81 | }); 82 | return !!result; 83 | }, 84 | mixin: $.extend, 85 | identity: function(x) { 86 | return x; 87 | }, 88 | clone: function(obj) { 89 | return $.extend(true, {}, obj); 90 | }, 91 | getIdGenerator: function() { 92 | var counter = 0; 93 | return function() { 94 | return counter++; 95 | }; 96 | }, 97 | templatify: function templatify(obj) { 98 | return $.isFunction(obj) ? obj : template; 99 | function template() { 100 | return String(obj); 101 | } 102 | }, 103 | defer: function(fn) { 104 | setTimeout(fn, 0); 105 | }, 106 | debounce: function(func, wait, immediate) { 107 | var timeout, result; 108 | return function() { 109 | var context = this, args = arguments, later, callNow; 110 | later = function() { 111 | timeout = null; 112 | if (!immediate) { 113 | result = func.apply(context, args); 114 | } 115 | }; 116 | callNow = immediate && !timeout; 117 | clearTimeout(timeout); 118 | timeout = setTimeout(later, wait); 119 | if (callNow) { 120 | result = func.apply(context, args); 121 | } 122 | return result; 123 | }; 124 | }, 125 | throttle: function(func, wait) { 126 | var context, args, timeout, result, previous, later; 127 | previous = 0; 128 | later = function() { 129 | previous = new Date(); 130 | timeout = null; 131 | result = func.apply(context, args); 132 | }; 133 | return function() { 134 | var now = new Date(), remaining = wait - (now - previous); 135 | context = this; 136 | args = arguments; 137 | if (remaining <= 0) { 138 | clearTimeout(timeout); 139 | timeout = null; 140 | previous = now; 141 | result = func.apply(context, args); 142 | } else if (!timeout) { 143 | timeout = setTimeout(later, remaining); 144 | } 145 | return result; 146 | }; 147 | }, 148 | stringify: function(val) { 149 | return _.isString(val) ? val : JSON.stringify(val); 150 | }, 151 | guid: function() { 152 | function _p8(s) { 153 | var p = (Math.random().toString(16) + "000000000").substr(2, 8); 154 | return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p; 155 | } 156 | return "tt-" + _p8() + _p8(true) + _p8(true) + _p8(); 157 | }, 158 | noop: function() {} 159 | }; 160 | }(); 161 | var VERSION = "1.2.0"; 162 | var tokenizers = function() { 163 | "use strict"; 164 | return { 165 | nonword: nonword, 166 | whitespace: whitespace, 167 | ngram: ngram, 168 | obj: { 169 | nonword: getObjTokenizer(nonword), 170 | whitespace: getObjTokenizer(whitespace), 171 | ngram: getObjTokenizer(ngram) 172 | } 173 | }; 174 | function whitespace(str) { 175 | str = _.toStr(str); 176 | return str ? str.split(/\s+/) : []; 177 | } 178 | function nonword(str) { 179 | str = _.toStr(str); 180 | return str ? str.split(/\W+/) : []; 181 | } 182 | function ngram(str) { 183 | str = _.toStr(str); 184 | var tokens = [], word = ""; 185 | _.each(str.split(""), function(char) { 186 | if (char.match(/\s+/)) { 187 | word = ""; 188 | } else { 189 | tokens.push(word + char); 190 | word += char; 191 | } 192 | }); 193 | return tokens; 194 | } 195 | function getObjTokenizer(tokenizer) { 196 | return function setKey(keys) { 197 | keys = _.isArray(keys) ? keys : [].slice.call(arguments, 0); 198 | return function tokenize(o) { 199 | var tokens = []; 200 | _.each(keys, function(k) { 201 | tokens = tokens.concat(tokenizer(_.toStr(o[k]))); 202 | }); 203 | return tokens; 204 | }; 205 | }; 206 | } 207 | }(); 208 | var LruCache = function() { 209 | "use strict"; 210 | function LruCache(maxSize) { 211 | this.maxSize = _.isNumber(maxSize) ? maxSize : 100; 212 | this.reset(); 213 | if (this.maxSize <= 0) { 214 | this.set = this.get = $.noop; 215 | } 216 | } 217 | _.mixin(LruCache.prototype, { 218 | set: function set(key, val) { 219 | var tailItem = this.list.tail, node; 220 | if (this.size >= this.maxSize) { 221 | this.list.remove(tailItem); 222 | delete this.hash[tailItem.key]; 223 | this.size--; 224 | } 225 | if (node = this.hash[key]) { 226 | node.val = val; 227 | this.list.moveToFront(node); 228 | } else { 229 | node = new Node(key, val); 230 | this.list.add(node); 231 | this.hash[key] = node; 232 | this.size++; 233 | } 234 | }, 235 | get: function get(key) { 236 | var node = this.hash[key]; 237 | if (node) { 238 | this.list.moveToFront(node); 239 | return node.val; 240 | } 241 | }, 242 | reset: function reset() { 243 | this.size = 0; 244 | this.hash = {}; 245 | this.list = new List(); 246 | } 247 | }); 248 | function List() { 249 | this.head = this.tail = null; 250 | } 251 | _.mixin(List.prototype, { 252 | add: function add(node) { 253 | if (this.head) { 254 | node.next = this.head; 255 | this.head.prev = node; 256 | } 257 | this.head = node; 258 | this.tail = this.tail || node; 259 | }, 260 | remove: function remove(node) { 261 | node.prev ? node.prev.next = node.next : this.head = node.next; 262 | node.next ? node.next.prev = node.prev : this.tail = node.prev; 263 | }, 264 | moveToFront: function(node) { 265 | this.remove(node); 266 | this.add(node); 267 | } 268 | }); 269 | function Node(key, val) { 270 | this.key = key; 271 | this.val = val; 272 | this.prev = this.next = null; 273 | } 274 | return LruCache; 275 | }(); 276 | var PersistentStorage = function() { 277 | "use strict"; 278 | var LOCAL_STORAGE; 279 | try { 280 | LOCAL_STORAGE = window.localStorage; 281 | LOCAL_STORAGE.setItem("~~~", "!"); 282 | LOCAL_STORAGE.removeItem("~~~"); 283 | } catch (err) { 284 | LOCAL_STORAGE = null; 285 | } 286 | function PersistentStorage(namespace, override) { 287 | this.prefix = [ "__", namespace, "__" ].join(""); 288 | this.ttlKey = "__ttl__"; 289 | this.keyMatcher = new RegExp("^" + _.escapeRegExChars(this.prefix)); 290 | this.ls = override || LOCAL_STORAGE; 291 | !this.ls && this._noop(); 292 | } 293 | _.mixin(PersistentStorage.prototype, { 294 | _prefix: function(key) { 295 | return this.prefix + key; 296 | }, 297 | _ttlKey: function(key) { 298 | return this._prefix(key) + this.ttlKey; 299 | }, 300 | _noop: function() { 301 | this.get = this.set = this.remove = this.clear = this.isExpired = _.noop; 302 | }, 303 | _safeSet: function(key, val) { 304 | try { 305 | this.ls.setItem(key, val); 306 | } catch (err) { 307 | if (err.name === "QuotaExceededError") { 308 | this.clear(); 309 | this._noop(); 310 | } 311 | } 312 | }, 313 | get: function(key) { 314 | if (this.isExpired(key)) { 315 | this.remove(key); 316 | } 317 | return decode(this.ls.getItem(this._prefix(key))); 318 | }, 319 | set: function(key, val, ttl) { 320 | if (_.isNumber(ttl)) { 321 | this._safeSet(this._ttlKey(key), encode(now() + ttl)); 322 | } else { 323 | this.ls.removeItem(this._ttlKey(key)); 324 | } 325 | return this._safeSet(this._prefix(key), encode(val)); 326 | }, 327 | remove: function(key) { 328 | this.ls.removeItem(this._ttlKey(key)); 329 | this.ls.removeItem(this._prefix(key)); 330 | return this; 331 | }, 332 | clear: function() { 333 | var i, keys = gatherMatchingKeys(this.keyMatcher); 334 | for (i = keys.length; i--; ) { 335 | this.remove(keys[i]); 336 | } 337 | return this; 338 | }, 339 | isExpired: function(key) { 340 | var ttl = decode(this.ls.getItem(this._ttlKey(key))); 341 | return _.isNumber(ttl) && now() > ttl ? true : false; 342 | } 343 | }); 344 | return PersistentStorage; 345 | function now() { 346 | return new Date().getTime(); 347 | } 348 | function encode(val) { 349 | return JSON.stringify(_.isUndefined(val) ? null : val); 350 | } 351 | function decode(val) { 352 | return $.parseJSON(val); 353 | } 354 | function gatherMatchingKeys(keyMatcher) { 355 | var i, key, keys = [], len = LOCAL_STORAGE.length; 356 | for (i = 0; i < len; i++) { 357 | if ((key = LOCAL_STORAGE.key(i)).match(keyMatcher)) { 358 | keys.push(key.replace(keyMatcher, "")); 359 | } 360 | } 361 | return keys; 362 | } 363 | }(); 364 | var Transport = function() { 365 | "use strict"; 366 | var pendingRequestsCount = 0, pendingRequests = {}, sharedCache = new LruCache(10); 367 | function Transport(o) { 368 | o = o || {}; 369 | this.maxPendingRequests = o.maxPendingRequests || 6; 370 | this.cancelled = false; 371 | this.lastReq = null; 372 | this._send = o.transport; 373 | this._get = o.limiter ? o.limiter(this._get) : this._get; 374 | this._cache = o.cache === false ? new LruCache(0) : sharedCache; 375 | } 376 | Transport.setMaxPendingRequests = function setMaxPendingRequests(num) { 377 | this.maxPendingRequests = num; 378 | }; 379 | Transport.resetCache = function resetCache() { 380 | sharedCache.reset(); 381 | }; 382 | _.mixin(Transport.prototype, { 383 | _fingerprint: function fingerprint(o) { 384 | o = o || {}; 385 | return o.url + o.type + $.param(o.data || {}); 386 | }, 387 | _get: function(o, cb) { 388 | var that = this, fingerprint, jqXhr; 389 | fingerprint = this._fingerprint(o); 390 | if (this.cancelled || fingerprint !== this.lastReq) { 391 | return; 392 | } 393 | if (jqXhr = pendingRequests[fingerprint]) { 394 | jqXhr.done(done).fail(fail); 395 | } else if (pendingRequestsCount < this.maxPendingRequests) { 396 | pendingRequestsCount++; 397 | pendingRequests[fingerprint] = this._send(o).done(done).fail(fail).always(always); 398 | } else { 399 | this.onDeckRequestArgs = [].slice.call(arguments, 0); 400 | } 401 | function done(resp) { 402 | cb(null, resp); 403 | that._cache.set(fingerprint, resp); 404 | } 405 | function fail() { 406 | cb(true); 407 | } 408 | function always() { 409 | pendingRequestsCount--; 410 | delete pendingRequests[fingerprint]; 411 | if (that.onDeckRequestArgs) { 412 | that._get.apply(that, that.onDeckRequestArgs); 413 | that.onDeckRequestArgs = null; 414 | } 415 | } 416 | }, 417 | get: function(o, cb) { 418 | var resp, fingerprint; 419 | cb = cb || $.noop; 420 | o = _.isString(o) ? { 421 | url: o 422 | } : o || {}; 423 | fingerprint = this._fingerprint(o); 424 | this.cancelled = false; 425 | this.lastReq = fingerprint; 426 | if (resp = this._cache.get(fingerprint)) { 427 | cb(null, resp); 428 | } else { 429 | this._get(o, cb); 430 | } 431 | }, 432 | cancel: function() { 433 | this.cancelled = true; 434 | } 435 | }); 436 | return Transport; 437 | }(); 438 | var SearchIndex = window.SearchIndex = function() { 439 | "use strict"; 440 | var CHILDREN = "c", IDS = "i"; 441 | function SearchIndex(o) { 442 | o = o || {}; 443 | if (!o.datumTokenizer || !o.queryTokenizer) { 444 | $.error("datumTokenizer and queryTokenizer are both required"); 445 | } 446 | this.identify = o.identify || _.stringify; 447 | this.datumTokenizer = o.datumTokenizer; 448 | this.queryTokenizer = o.queryTokenizer; 449 | this.matchAnyQueryToken = o.matchAnyQueryToken; 450 | this.reset(); 451 | } 452 | _.mixin(SearchIndex.prototype, { 453 | bootstrap: function bootstrap(o) { 454 | this.datums = o.datums; 455 | this.trie = o.trie; 456 | }, 457 | add: function(data) { 458 | var that = this; 459 | data = _.isArray(data) ? data : [ data ]; 460 | _.each(data, function(datum) { 461 | var id, tokens; 462 | that.datums[id = that.identify(datum)] = datum; 463 | tokens = normalizeTokens(that.datumTokenizer(datum)); 464 | _.each(tokens, function(token) { 465 | var node, chars, ch; 466 | node = that.trie; 467 | chars = token.split(""); 468 | while (ch = chars.shift()) { 469 | node = node[CHILDREN][ch] || (node[CHILDREN][ch] = newNode()); 470 | node[IDS].push(id); 471 | } 472 | }); 473 | }); 474 | }, 475 | get: function get(ids) { 476 | var that = this; 477 | return _.map(ids, function(id) { 478 | return that.datums[id]; 479 | }); 480 | }, 481 | search: function search(query) { 482 | var that = this, tokens, matches; 483 | tokens = normalizeTokens(this.queryTokenizer(query)); 484 | _.each(tokens, function(token) { 485 | var node, chars, ch, ids; 486 | if (matches && matches.length === 0 && !that.matchAnyQueryToken) { 487 | return false; 488 | } 489 | node = that.trie; 490 | chars = token.split(""); 491 | while (node && (ch = chars.shift())) { 492 | node = node[CHILDREN][ch]; 493 | } 494 | if (node && chars.length === 0) { 495 | ids = node[IDS].slice(0); 496 | matches = matches ? getIntersection(matches, ids) : ids; 497 | } else { 498 | if (!that.matchAnyQueryToken) { 499 | matches = []; 500 | return false; 501 | } 502 | } 503 | }); 504 | return matches ? _.map(unique(matches), function(id) { 505 | return that.datums[id]; 506 | }) : []; 507 | }, 508 | all: function all() { 509 | var values = []; 510 | for (var key in this.datums) { 511 | values.push(this.datums[key]); 512 | } 513 | return values; 514 | }, 515 | reset: function reset() { 516 | this.datums = {}; 517 | this.trie = newNode(); 518 | }, 519 | serialize: function serialize() { 520 | return { 521 | datums: this.datums, 522 | trie: this.trie 523 | }; 524 | } 525 | }); 526 | return SearchIndex; 527 | function normalizeTokens(tokens) { 528 | tokens = _.filter(tokens, function(token) { 529 | return !!token; 530 | }); 531 | tokens = _.map(tokens, function(token) { 532 | return token.toLowerCase(); 533 | }); 534 | return tokens; 535 | } 536 | function newNode() { 537 | var node = {}; 538 | node[IDS] = []; 539 | node[CHILDREN] = {}; 540 | return node; 541 | } 542 | function unique(array) { 543 | var seen = {}, uniques = []; 544 | for (var i = 0, len = array.length; i < len; i++) { 545 | if (!seen[array[i]]) { 546 | seen[array[i]] = true; 547 | uniques.push(array[i]); 548 | } 549 | } 550 | return uniques; 551 | } 552 | function getIntersection(arrayA, arrayB) { 553 | var ai = 0, bi = 0, intersection = []; 554 | arrayA = arrayA.sort(); 555 | arrayB = arrayB.sort(); 556 | var lenArrayA = arrayA.length, lenArrayB = arrayB.length; 557 | while (ai < lenArrayA && bi < lenArrayB) { 558 | if (arrayA[ai] < arrayB[bi]) { 559 | ai++; 560 | } else if (arrayA[ai] > arrayB[bi]) { 561 | bi++; 562 | } else { 563 | intersection.push(arrayA[ai]); 564 | ai++; 565 | bi++; 566 | } 567 | } 568 | return intersection; 569 | } 570 | }(); 571 | var Prefetch = function() { 572 | "use strict"; 573 | var keys; 574 | keys = { 575 | data: "data", 576 | protocol: "protocol", 577 | thumbprint: "thumbprint" 578 | }; 579 | function Prefetch(o) { 580 | this.url = o.url; 581 | this.ttl = o.ttl; 582 | this.cache = o.cache; 583 | this.prepare = o.prepare; 584 | this.transform = o.transform; 585 | this.transport = o.transport; 586 | this.thumbprint = o.thumbprint; 587 | this.storage = new PersistentStorage(o.cacheKey); 588 | } 589 | _.mixin(Prefetch.prototype, { 590 | _settings: function settings() { 591 | return { 592 | url: this.url, 593 | type: "GET", 594 | dataType: "json" 595 | }; 596 | }, 597 | store: function store(data) { 598 | if (!this.cache) { 599 | return; 600 | } 601 | this.storage.set(keys.data, data, this.ttl); 602 | this.storage.set(keys.protocol, location.protocol, this.ttl); 603 | this.storage.set(keys.thumbprint, this.thumbprint, this.ttl); 604 | }, 605 | fromCache: function fromCache() { 606 | var stored = {}, isExpired; 607 | if (!this.cache) { 608 | return null; 609 | } 610 | stored.data = this.storage.get(keys.data); 611 | stored.protocol = this.storage.get(keys.protocol); 612 | stored.thumbprint = this.storage.get(keys.thumbprint); 613 | isExpired = stored.thumbprint !== this.thumbprint || stored.protocol !== location.protocol; 614 | return stored.data && !isExpired ? stored.data : null; 615 | }, 616 | fromNetwork: function(cb) { 617 | var that = this, settings; 618 | if (!cb) { 619 | return; 620 | } 621 | settings = this.prepare(this._settings()); 622 | this.transport(settings).fail(onError).done(onResponse); 623 | function onError() { 624 | cb(true); 625 | } 626 | function onResponse(resp) { 627 | cb(null, that.transform(resp)); 628 | } 629 | }, 630 | clear: function clear() { 631 | this.storage.clear(); 632 | return this; 633 | } 634 | }); 635 | return Prefetch; 636 | }(); 637 | var Remote = function() { 638 | "use strict"; 639 | function Remote(o) { 640 | this.url = o.url; 641 | this.prepare = o.prepare; 642 | this.transform = o.transform; 643 | this.indexResponse = o.indexResponse; 644 | this.transport = new Transport({ 645 | cache: o.cache, 646 | limiter: o.limiter, 647 | transport: o.transport, 648 | maxPendingRequests: o.maxPendingRequests 649 | }); 650 | } 651 | _.mixin(Remote.prototype, { 652 | _settings: function settings() { 653 | return { 654 | url: this.url, 655 | type: "GET", 656 | dataType: "json" 657 | }; 658 | }, 659 | get: function get(query, cb) { 660 | var that = this, settings; 661 | if (!cb) { 662 | return; 663 | } 664 | query = query || ""; 665 | settings = this.prepare(query, this._settings()); 666 | return this.transport.get(settings, onResponse); 667 | function onResponse(err, resp) { 668 | err ? cb([]) : cb(that.transform(resp)); 669 | } 670 | }, 671 | cancelLastRequest: function cancelLastRequest() { 672 | this.transport.cancel(); 673 | } 674 | }); 675 | return Remote; 676 | }(); 677 | var oParser = function() { 678 | "use strict"; 679 | return function parse(o) { 680 | var defaults, sorter; 681 | defaults = { 682 | initialize: true, 683 | identify: _.stringify, 684 | datumTokenizer: null, 685 | queryTokenizer: null, 686 | matchAnyQueryToken: false, 687 | sufficient: 5, 688 | indexRemote: false, 689 | sorter: null, 690 | local: [], 691 | prefetch: null, 692 | remote: null 693 | }; 694 | o = _.mixin(defaults, o || {}); 695 | !o.datumTokenizer && $.error("datumTokenizer is required"); 696 | !o.queryTokenizer && $.error("queryTokenizer is required"); 697 | sorter = o.sorter; 698 | o.sorter = sorter ? function(x) { 699 | return x.sort(sorter); 700 | } : _.identity; 701 | o.local = _.isFunction(o.local) ? o.local() : o.local; 702 | o.prefetch = parsePrefetch(o.prefetch); 703 | o.remote = parseRemote(o.remote); 704 | return o; 705 | }; 706 | function parsePrefetch(o) { 707 | var defaults; 708 | if (!o) { 709 | return null; 710 | } 711 | defaults = { 712 | url: null, 713 | ttl: 24 * 60 * 60 * 1e3, 714 | cache: true, 715 | cacheKey: null, 716 | thumbprint: "", 717 | prepare: _.identity, 718 | transform: _.identity, 719 | transport: null 720 | }; 721 | o = _.isString(o) ? { 722 | url: o 723 | } : o; 724 | o = _.mixin(defaults, o); 725 | !o.url && $.error("prefetch requires url to be set"); 726 | o.transform = o.filter || o.transform; 727 | o.cacheKey = o.cacheKey || o.url; 728 | o.thumbprint = VERSION + o.thumbprint; 729 | o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; 730 | return o; 731 | } 732 | function parseRemote(o) { 733 | var defaults; 734 | if (!o) { 735 | return; 736 | } 737 | defaults = { 738 | url: null, 739 | cache: true, 740 | prepare: null, 741 | replace: null, 742 | wildcard: null, 743 | limiter: null, 744 | rateLimitBy: "debounce", 745 | rateLimitWait: 300, 746 | transform: _.identity, 747 | transport: null 748 | }; 749 | o = _.isString(o) ? { 750 | url: o 751 | } : o; 752 | o = _.mixin(defaults, o); 753 | !o.url && $.error("remote requires url to be set"); 754 | o.transform = o.filter || o.transform; 755 | o.prepare = toRemotePrepare(o); 756 | o.limiter = toLimiter(o); 757 | o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; 758 | delete o.replace; 759 | delete o.wildcard; 760 | delete o.rateLimitBy; 761 | delete o.rateLimitWait; 762 | return o; 763 | } 764 | function toRemotePrepare(o) { 765 | var prepare, replace, wildcard; 766 | prepare = o.prepare; 767 | replace = o.replace; 768 | wildcard = o.wildcard; 769 | if (prepare) { 770 | return prepare; 771 | } 772 | if (replace) { 773 | prepare = prepareByReplace; 774 | } else if (o.wildcard) { 775 | prepare = prepareByWildcard; 776 | } else { 777 | prepare = identityPrepare; 778 | } 779 | return prepare; 780 | function prepareByReplace(query, settings) { 781 | settings.url = replace(settings.url, query); 782 | return settings; 783 | } 784 | function prepareByWildcard(query, settings) { 785 | settings.url = settings.url.replace(wildcard, encodeURIComponent(query)); 786 | return settings; 787 | } 788 | function identityPrepare(query, settings) { 789 | return settings; 790 | } 791 | } 792 | function toLimiter(o) { 793 | var limiter, method, wait; 794 | limiter = o.limiter; 795 | method = o.rateLimitBy; 796 | wait = o.rateLimitWait; 797 | if (!limiter) { 798 | limiter = /^throttle$/i.test(method) ? throttle(wait) : debounce(wait); 799 | } 800 | return limiter; 801 | function debounce(wait) { 802 | return function debounce(fn) { 803 | return _.debounce(fn, wait); 804 | }; 805 | } 806 | function throttle(wait) { 807 | return function throttle(fn) { 808 | return _.throttle(fn, wait); 809 | }; 810 | } 811 | } 812 | function callbackToDeferred(fn) { 813 | return function wrapper(o) { 814 | var deferred = $.Deferred(); 815 | fn(o, onSuccess, onError); 816 | return deferred; 817 | function onSuccess(resp) { 818 | _.defer(function() { 819 | deferred.resolve(resp); 820 | }); 821 | } 822 | function onError(err) { 823 | _.defer(function() { 824 | deferred.reject(err); 825 | }); 826 | } 827 | }; 828 | } 829 | }(); 830 | var Bloodhound = function() { 831 | "use strict"; 832 | var old; 833 | old = window && window.Bloodhound; 834 | function Bloodhound(o) { 835 | o = oParser(o); 836 | this.sorter = o.sorter; 837 | this.identify = o.identify; 838 | this.sufficient = o.sufficient; 839 | this.indexRemote = o.indexRemote; 840 | this.local = o.local; 841 | this.remote = o.remote ? new Remote(o.remote) : null; 842 | this.prefetch = o.prefetch ? new Prefetch(o.prefetch) : null; 843 | this.index = new SearchIndex({ 844 | identify: this.identify, 845 | datumTokenizer: o.datumTokenizer, 846 | queryTokenizer: o.queryTokenizer 847 | }); 848 | o.initialize !== false && this.initialize(); 849 | } 850 | Bloodhound.noConflict = function noConflict() { 851 | window && (window.Bloodhound = old); 852 | return Bloodhound; 853 | }; 854 | Bloodhound.tokenizers = tokenizers; 855 | _.mixin(Bloodhound.prototype, { 856 | __ttAdapter: function ttAdapter() { 857 | var that = this; 858 | return this.remote ? withAsync : withoutAsync; 859 | function withAsync(query, sync, async) { 860 | return that.search(query, sync, async); 861 | } 862 | function withoutAsync(query, sync) { 863 | return that.search(query, sync); 864 | } 865 | }, 866 | _loadPrefetch: function loadPrefetch() { 867 | var that = this, deferred, serialized; 868 | deferred = $.Deferred(); 869 | if (!this.prefetch) { 870 | deferred.resolve(); 871 | } else if (serialized = this.prefetch.fromCache()) { 872 | this.index.bootstrap(serialized); 873 | deferred.resolve(); 874 | } else { 875 | this.prefetch.fromNetwork(done); 876 | } 877 | return deferred.promise(); 878 | function done(err, data) { 879 | if (err) { 880 | return deferred.reject(); 881 | } 882 | that.add(data); 883 | that.prefetch.store(that.index.serialize()); 884 | deferred.resolve(); 885 | } 886 | }, 887 | _initialize: function initialize() { 888 | var that = this, deferred; 889 | this.clear(); 890 | (this.initPromise = this._loadPrefetch()).done(addLocalToIndex); 891 | return this.initPromise; 892 | function addLocalToIndex() { 893 | that.add(that.local); 894 | } 895 | }, 896 | initialize: function initialize(force) { 897 | return !this.initPromise || force ? this._initialize() : this.initPromise; 898 | }, 899 | add: function add(data) { 900 | this.index.add(data); 901 | return this; 902 | }, 903 | get: function get(ids) { 904 | ids = _.isArray(ids) ? ids : [].slice.call(arguments); 905 | return this.index.get(ids); 906 | }, 907 | search: function search(query, sync, async) { 908 | var that = this, local; 909 | sync = sync || _.noop; 910 | async = async || _.noop; 911 | local = this.sorter(this.index.search(query)); 912 | sync(this.remote ? local.slice() : local); 913 | if (this.remote && local.length < this.sufficient) { 914 | this.remote.get(query, processRemote); 915 | } else if (this.remote) { 916 | this.remote.cancelLastRequest(); 917 | } 918 | return this; 919 | function processRemote(remote) { 920 | var nonDuplicates = []; 921 | _.each(remote, function(r) { 922 | !_.some(local, function(l) { 923 | return that.identify(r) === that.identify(l); 924 | }) && nonDuplicates.push(r); 925 | }); 926 | that.indexRemote && that.add(nonDuplicates); 927 | async(nonDuplicates); 928 | } 929 | }, 930 | all: function all() { 931 | return this.index.all(); 932 | }, 933 | clear: function clear() { 934 | this.index.reset(); 935 | return this; 936 | }, 937 | clearPrefetchCache: function clearPrefetchCache() { 938 | this.prefetch && this.prefetch.clear(); 939 | return this; 940 | }, 941 | clearRemoteCache: function clearRemoteCache() { 942 | Transport.resetCache(); 943 | return this; 944 | }, 945 | ttAdapter: function ttAdapter() { 946 | return this.__ttAdapter(); 947 | } 948 | }); 949 | return Bloodhound; 950 | }(); 951 | return Bloodhound; 952 | }); 953 | 954 | (function(root, factory) { 955 | if (typeof define === "function" && define.amd) { 956 | define([ "jquery" ], function(a0) { 957 | return factory(a0); 958 | }); 959 | } else if (typeof exports === "object") { 960 | module.exports = factory(require("jquery")); 961 | } else { 962 | factory(root["jQuery"]); 963 | } 964 | })(this, function($) { 965 | var _ = function() { 966 | "use strict"; 967 | return { 968 | isMsie: function() { 969 | return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; 970 | }, 971 | isBlankString: function(str) { 972 | return !str || /^\s*$/.test(str); 973 | }, 974 | escapeRegExChars: function(str) { 975 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); 976 | }, 977 | isString: function(obj) { 978 | return typeof obj === "string"; 979 | }, 980 | isNumber: function(obj) { 981 | return typeof obj === "number"; 982 | }, 983 | isArray: $.isArray, 984 | isFunction: $.isFunction, 985 | isObject: $.isPlainObject, 986 | isUndefined: function(obj) { 987 | return typeof obj === "undefined"; 988 | }, 989 | isElement: function(obj) { 990 | return !!(obj && obj.nodeType === 1); 991 | }, 992 | isJQuery: function(obj) { 993 | return obj instanceof $; 994 | }, 995 | toStr: function toStr(s) { 996 | return _.isUndefined(s) || s === null ? "" : s + ""; 997 | }, 998 | bind: $.proxy, 999 | each: function(collection, cb) { 1000 | $.each(collection, reverseArgs); 1001 | function reverseArgs(index, value) { 1002 | return cb(value, index); 1003 | } 1004 | }, 1005 | map: $.map, 1006 | filter: $.grep, 1007 | every: function(obj, test) { 1008 | var result = true; 1009 | if (!obj) { 1010 | return result; 1011 | } 1012 | $.each(obj, function(key, val) { 1013 | if (!(result = test.call(null, val, key, obj))) { 1014 | return false; 1015 | } 1016 | }); 1017 | return !!result; 1018 | }, 1019 | some: function(obj, test) { 1020 | var result = false; 1021 | if (!obj) { 1022 | return result; 1023 | } 1024 | $.each(obj, function(key, val) { 1025 | if (result = test.call(null, val, key, obj)) { 1026 | return false; 1027 | } 1028 | }); 1029 | return !!result; 1030 | }, 1031 | mixin: $.extend, 1032 | identity: function(x) { 1033 | return x; 1034 | }, 1035 | clone: function(obj) { 1036 | return $.extend(true, {}, obj); 1037 | }, 1038 | getIdGenerator: function() { 1039 | var counter = 0; 1040 | return function() { 1041 | return counter++; 1042 | }; 1043 | }, 1044 | templatify: function templatify(obj) { 1045 | return $.isFunction(obj) ? obj : template; 1046 | function template() { 1047 | return String(obj); 1048 | } 1049 | }, 1050 | defer: function(fn) { 1051 | setTimeout(fn, 0); 1052 | }, 1053 | debounce: function(func, wait, immediate) { 1054 | var timeout, result; 1055 | return function() { 1056 | var context = this, args = arguments, later, callNow; 1057 | later = function() { 1058 | timeout = null; 1059 | if (!immediate) { 1060 | result = func.apply(context, args); 1061 | } 1062 | }; 1063 | callNow = immediate && !timeout; 1064 | clearTimeout(timeout); 1065 | timeout = setTimeout(later, wait); 1066 | if (callNow) { 1067 | result = func.apply(context, args); 1068 | } 1069 | return result; 1070 | }; 1071 | }, 1072 | throttle: function(func, wait) { 1073 | var context, args, timeout, result, previous, later; 1074 | previous = 0; 1075 | later = function() { 1076 | previous = new Date(); 1077 | timeout = null; 1078 | result = func.apply(context, args); 1079 | }; 1080 | return function() { 1081 | var now = new Date(), remaining = wait - (now - previous); 1082 | context = this; 1083 | args = arguments; 1084 | if (remaining <= 0) { 1085 | clearTimeout(timeout); 1086 | timeout = null; 1087 | previous = now; 1088 | result = func.apply(context, args); 1089 | } else if (!timeout) { 1090 | timeout = setTimeout(later, remaining); 1091 | } 1092 | return result; 1093 | }; 1094 | }, 1095 | stringify: function(val) { 1096 | return _.isString(val) ? val : JSON.stringify(val); 1097 | }, 1098 | guid: function() { 1099 | function _p8(s) { 1100 | var p = (Math.random().toString(16) + "000000000").substr(2, 8); 1101 | return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p; 1102 | } 1103 | return "tt-" + _p8() + _p8(true) + _p8(true) + _p8(); 1104 | }, 1105 | noop: function() {} 1106 | }; 1107 | }(); 1108 | var WWW = function() { 1109 | "use strict"; 1110 | var defaultClassNames = { 1111 | wrapper: "twitter-typeahead", 1112 | input: "tt-input", 1113 | hint: "tt-hint", 1114 | menu: "tt-menu", 1115 | dataset: "tt-dataset", 1116 | suggestion: "tt-suggestion", 1117 | selectable: "tt-selectable", 1118 | empty: "tt-empty", 1119 | open: "tt-open", 1120 | cursor: "tt-cursor", 1121 | highlight: "tt-highlight" 1122 | }; 1123 | return build; 1124 | function build(o) { 1125 | var www, classes; 1126 | classes = _.mixin({}, defaultClassNames, o); 1127 | www = { 1128 | css: buildCss(), 1129 | classes: classes, 1130 | html: buildHtml(classes), 1131 | selectors: buildSelectors(classes) 1132 | }; 1133 | return { 1134 | css: www.css, 1135 | html: www.html, 1136 | classes: www.classes, 1137 | selectors: www.selectors, 1138 | mixin: function(o) { 1139 | _.mixin(o, www); 1140 | } 1141 | }; 1142 | } 1143 | function buildHtml(c) { 1144 | return { 1145 | wrapper: '', 1146 | menu: '
' 1147 | }; 1148 | } 1149 | function buildSelectors(classes) { 1150 | var selectors = {}; 1151 | _.each(classes, function(v, k) { 1152 | selectors[k] = "." + v; 1153 | }); 1154 | return selectors; 1155 | } 1156 | function buildCss() { 1157 | var css = { 1158 | wrapper: { 1159 | position: "relative", 1160 | display: "inline-block" 1161 | }, 1162 | hint: { 1163 | position: "absolute", 1164 | top: "0", 1165 | left: "0", 1166 | borderColor: "transparent", 1167 | boxShadow: "none", 1168 | opacity: "1" 1169 | }, 1170 | input: { 1171 | position: "relative", 1172 | verticalAlign: "top", 1173 | backgroundColor: "transparent" 1174 | }, 1175 | inputWithNoHint: { 1176 | position: "relative", 1177 | verticalAlign: "top" 1178 | }, 1179 | menu: { 1180 | position: "absolute", 1181 | top: "100%", 1182 | right: "0", 1183 | zIndex: "100", 1184 | display: "none" 1185 | }, 1186 | ltr: { 1187 | left: "0", 1188 | right: "auto" 1189 | }, 1190 | rtl: { 1191 | left: "auto", 1192 | right: " 0" 1193 | } 1194 | }; 1195 | if (_.isMsie()) { 1196 | _.mixin(css.input, { 1197 | backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" 1198 | }); 1199 | } 1200 | return css; 1201 | } 1202 | }(); 1203 | var EventBus = function() { 1204 | "use strict"; 1205 | var namespace, deprecationMap; 1206 | namespace = "typeahead:"; 1207 | deprecationMap = { 1208 | render: "rendered", 1209 | cursorchange: "cursorchanged", 1210 | select: "selected", 1211 | autocomplete: "autocompleted" 1212 | }; 1213 | function EventBus(o) { 1214 | if (!o || !o.el) { 1215 | $.error("EventBus initialized without el"); 1216 | } 1217 | this.$el = $(o.el); 1218 | } 1219 | _.mixin(EventBus.prototype, { 1220 | _trigger: function(type, args) { 1221 | var $e = $.Event(namespace + type); 1222 | this.$el.trigger.call(this.$el, $e, args || []); 1223 | return $e; 1224 | }, 1225 | before: function(type) { 1226 | var args, $e; 1227 | args = [].slice.call(arguments, 1); 1228 | $e = this._trigger("before" + type, args); 1229 | return $e.isDefaultPrevented(); 1230 | }, 1231 | trigger: function(type) { 1232 | var deprecatedType; 1233 | this._trigger(type, [].slice.call(arguments, 1)); 1234 | if (deprecatedType = deprecationMap[type]) { 1235 | this._trigger(deprecatedType, [].slice.call(arguments, 1)); 1236 | } 1237 | } 1238 | }); 1239 | return EventBus; 1240 | }(); 1241 | var EventEmitter = function() { 1242 | "use strict"; 1243 | var splitter = /\s+/, nextTick = getNextTick(); 1244 | return { 1245 | onSync: onSync, 1246 | onAsync: onAsync, 1247 | off: off, 1248 | trigger: trigger 1249 | }; 1250 | function on(method, types, cb, context) { 1251 | var type; 1252 | if (!cb) { 1253 | return this; 1254 | } 1255 | types = types.split(splitter); 1256 | cb = context ? bindContext(cb, context) : cb; 1257 | this._callbacks = this._callbacks || {}; 1258 | while (type = types.shift()) { 1259 | this._callbacks[type] = this._callbacks[type] || { 1260 | sync: [], 1261 | async: [] 1262 | }; 1263 | this._callbacks[type][method].push(cb); 1264 | } 1265 | return this; 1266 | } 1267 | function onAsync(types, cb, context) { 1268 | return on.call(this, "async", types, cb, context); 1269 | } 1270 | function onSync(types, cb, context) { 1271 | return on.call(this, "sync", types, cb, context); 1272 | } 1273 | function off(types) { 1274 | var type; 1275 | if (!this._callbacks) { 1276 | return this; 1277 | } 1278 | types = types.split(splitter); 1279 | while (type = types.shift()) { 1280 | delete this._callbacks[type]; 1281 | } 1282 | return this; 1283 | } 1284 | function trigger(types) { 1285 | var type, callbacks, args, syncFlush, asyncFlush; 1286 | if (!this._callbacks) { 1287 | return this; 1288 | } 1289 | types = types.split(splitter); 1290 | args = [].slice.call(arguments, 1); 1291 | while ((type = types.shift()) && (callbacks = this._callbacks[type])) { 1292 | syncFlush = getFlush(callbacks.sync, this, [ type ].concat(args)); 1293 | asyncFlush = getFlush(callbacks.async, this, [ type ].concat(args)); 1294 | syncFlush() && nextTick(asyncFlush); 1295 | } 1296 | return this; 1297 | } 1298 | function getFlush(callbacks, context, args) { 1299 | return flush; 1300 | function flush() { 1301 | var cancelled; 1302 | for (var i = 0, len = callbacks.length; !cancelled && i < len; i += 1) { 1303 | cancelled = callbacks[i].apply(context, args) === false; 1304 | } 1305 | return !cancelled; 1306 | } 1307 | } 1308 | function getNextTick() { 1309 | var nextTickFn; 1310 | if (window.setImmediate) { 1311 | nextTickFn = function nextTickSetImmediate(fn) { 1312 | setImmediate(function() { 1313 | fn(); 1314 | }); 1315 | }; 1316 | } else { 1317 | nextTickFn = function nextTickSetTimeout(fn) { 1318 | setTimeout(function() { 1319 | fn(); 1320 | }, 0); 1321 | }; 1322 | } 1323 | return nextTickFn; 1324 | } 1325 | function bindContext(fn, context) { 1326 | return fn.bind ? fn.bind(context) : function() { 1327 | fn.apply(context, [].slice.call(arguments, 0)); 1328 | }; 1329 | } 1330 | }(); 1331 | var highlight = function(doc) { 1332 | "use strict"; 1333 | var defaults = { 1334 | node: null, 1335 | pattern: null, 1336 | tagName: "strong", 1337 | className: null, 1338 | wordsOnly: false, 1339 | caseSensitive: false, 1340 | diacriticInsensitive: false 1341 | }; 1342 | var accented = { 1343 | A: "[AaªÀ-Åà-åĀ-ąǍǎȀ-ȃȦȧᴬᵃḀḁẚẠ-ảₐ℀℁℻⒜Ⓐⓐ㍱-㍴㎀-㎄㎈㎉㎩-㎯㏂㏊㏟㏿Aa]", 1344 | B: "[BbᴮᵇḂ-ḇℬ⒝Ⓑⓑ㍴㎅-㎇㏃㏈㏔㏝Bb]", 1345 | C: "[CcÇçĆ-čᶜ℀ℂ℃℅℆ℭⅭⅽ⒞Ⓒⓒ㍶㎈㎉㎝㎠㎤㏄-㏇Cc]", 1346 | D: "[DdĎďDŽ-džDZ-dzᴰᵈḊ-ḓⅅⅆⅮⅾ⒟Ⓓⓓ㋏㍲㍷-㍹㎗㎭-㎯㏅㏈Dd]", 1347 | E: "[EeÈ-Ëè-ëĒ-ěȄ-ȇȨȩᴱᵉḘ-ḛẸ-ẽₑ℡ℯℰⅇ⒠Ⓔⓔ㉐㋍㋎Ee]", 1348 | F: "[FfᶠḞḟ℉ℱ℻⒡Ⓕⓕ㎊-㎌㎙ff-fflFf]", 1349 | G: "[GgĜ-ģǦǧǴǵᴳᵍḠḡℊ⒢Ⓖⓖ㋌㋍㎇㎍-㎏㎓㎬㏆㏉㏒㏿Gg]", 1350 | H: "[HhĤĥȞȟʰᴴḢ-ḫẖℋ-ℎ⒣Ⓗⓗ㋌㍱㎐-㎔㏊㏋㏗Hh]", 1351 | I: "[IiÌ-Ïì-ïĨ-İIJijǏǐȈ-ȋᴵᵢḬḭỈ-ịⁱℐℑℹⅈⅠ-ⅣⅥ-ⅨⅪⅫⅰ-ⅳⅵ-ⅸⅺⅻ⒤Ⓘⓘ㍺㏌㏕fiffiIi]", 1352 | J: "[JjIJ-ĵLJ-njǰʲᴶⅉ⒥ⒿⓙⱼJj]", 1353 | K: "[KkĶķǨǩᴷᵏḰ-ḵK⒦Ⓚⓚ㎄㎅㎉㎏㎑㎘㎞㎢㎦㎪㎸㎾㏀㏆㏍-㏏Kk]", 1354 | L: "[LlĹ-ŀLJ-ljˡᴸḶḷḺ-ḽℒℓ℡Ⅼⅼ⒧Ⓛⓛ㋏㎈㎉㏐-㏓㏕㏖㏿flfflLl]", 1355 | M: "[MmᴹᵐḾ-ṃ℠™ℳⅯⅿ⒨Ⓜⓜ㍷-㍹㎃㎆㎎㎒㎖㎙-㎨㎫㎳㎷㎹㎽㎿㏁㏂㏎㏐㏔-㏖㏘㏙㏞㏟Mm]", 1356 | N: "[NnÑñŃ-ʼnNJ-njǸǹᴺṄ-ṋⁿℕ№⒩Ⓝⓝ㎁㎋㎚㎱㎵㎻㏌㏑Nn]", 1357 | O: "[OoºÒ-Öò-öŌ-őƠơǑǒǪǫȌ-ȏȮȯᴼᵒỌ-ỏₒ℅№ℴ⒪Ⓞⓞ㍵㏇㏒㏖Oo]", 1358 | P: "[PpᴾᵖṔ-ṗℙ⒫Ⓟⓟ㉐㍱㍶㎀㎊㎩-㎬㎰㎴㎺㏋㏗-㏚Pp]", 1359 | Q: "[Qqℚ⒬Ⓠⓠ㏃Qq]", 1360 | R: "[RrŔ-řȐ-ȓʳᴿᵣṘ-ṛṞṟ₨ℛ-ℝ⒭Ⓡⓡ㋍㍴㎭-㎯㏚㏛Rr]", 1361 | S: "[SsŚ-šſȘșˢṠ-ṣ₨℁℠⒮Ⓢⓢ㎧㎨㎮-㎳㏛㏜stSs]", 1362 | T: "[TtŢ-ťȚțᵀᵗṪ-ṱẗ℡™⒯Ⓣⓣ㉐㋏㎔㏏ſtstTt]", 1363 | U: "[UuÙ-Üù-üŨ-ųƯưǓǔȔ-ȗᵁᵘᵤṲ-ṷỤ-ủ℆⒰Ⓤⓤ㍳㍺Uu]", 1364 | V: "[VvᵛᵥṼ-ṿⅣ-Ⅷⅳ-ⅷ⒱Ⓥⓥⱽ㋎㍵㎴-㎹㏜㏞Vv]", 1365 | W: "[WwŴŵʷᵂẀ-ẉẘ⒲Ⓦⓦ㎺-㎿㏝Ww]", 1366 | X: "[XxˣẊ-ẍₓ℻Ⅸ-Ⅻⅸ-ⅻ⒳Ⓧⓧ㏓Xx]", 1367 | Y: "[YyÝýÿŶ-ŸȲȳʸẎẏẙỲ-ỹ⒴Ⓨⓨ㏉Yy]", 1368 | Z: "[ZzŹ-žDZ-dzᶻẐ-ẕℤℨ⒵Ⓩⓩ㎐-㎔Zz]" 1369 | }; 1370 | return function hightlight(o) { 1371 | var regex; 1372 | o = _.mixin({}, defaults, o); 1373 | if (!o.node || !o.pattern) { 1374 | return; 1375 | } 1376 | o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ]; 1377 | regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly, o.diacriticInsensitive); 1378 | traverse(o.node, hightlightTextNode); 1379 | function hightlightTextNode(textNode) { 1380 | var match, patternNode, wrapperNode; 1381 | if (match = regex.exec(textNode.data)) { 1382 | wrapperNode = doc.createElement(o.tagName); 1383 | o.className && (wrapperNode.className = o.className); 1384 | patternNode = textNode.splitText(match.index); 1385 | patternNode.splitText(match[0].length); 1386 | wrapperNode.appendChild(patternNode.cloneNode(true)); 1387 | textNode.parentNode.replaceChild(wrapperNode, patternNode); 1388 | } 1389 | return !!match; 1390 | } 1391 | function traverse(el, hightlightTextNode) { 1392 | var childNode, TEXT_NODE_TYPE = 3; 1393 | for (var i = 0; i < el.childNodes.length; i++) { 1394 | childNode = el.childNodes[i]; 1395 | if (childNode.nodeType === TEXT_NODE_TYPE) { 1396 | i += hightlightTextNode(childNode) ? 1 : 0; 1397 | } else { 1398 | traverse(childNode, hightlightTextNode); 1399 | } 1400 | } 1401 | } 1402 | }; 1403 | function accent_replacer(chr) { 1404 | return accented[chr.toUpperCase()] || chr; 1405 | } 1406 | function getRegex(patterns, caseSensitive, wordsOnly, diacriticInsensitive) { 1407 | var escapedPatterns = [], regexStr; 1408 | for (var i = 0, len = patterns.length; i < len; i++) { 1409 | var escapedWord = _.escapeRegExChars(patterns[i]); 1410 | if (diacriticInsensitive) { 1411 | escapedWord = escapedWord.replace(/\S/g, accent_replacer); 1412 | } 1413 | escapedPatterns.push(escapedWord); 1414 | } 1415 | regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")"; 1416 | return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i"); 1417 | } 1418 | }(window.document); 1419 | var Input = function() { 1420 | "use strict"; 1421 | var specialKeyCodeMap; 1422 | specialKeyCodeMap = { 1423 | 9: "tab", 1424 | 27: "esc", 1425 | 37: "left", 1426 | 39: "right", 1427 | 13: "enter", 1428 | 38: "up", 1429 | 40: "down" 1430 | }; 1431 | function Input(o, www) { 1432 | o = o || {}; 1433 | if (!o.input) { 1434 | $.error("input is missing"); 1435 | } 1436 | www.mixin(this); 1437 | this.$hint = $(o.hint); 1438 | this.$input = $(o.input); 1439 | this.$input.attr({ 1440 | "aria-activedescendant": "", 1441 | "aria-owns": this.$input.attr("id") + "_listbox", 1442 | role: "combobox", 1443 | "aria-readonly": "true", 1444 | "aria-autocomplete": "list" 1445 | }); 1446 | $(www.menu).attr("id", this.$input.attr("id") + "_listbox"); 1447 | this.query = this.$input.val(); 1448 | this.queryWhenFocused = this.hasFocus() ? this.query : null; 1449 | this.$overflowHelper = buildOverflowHelper(this.$input); 1450 | this._checkLanguageDirection(); 1451 | if (this.$hint.length === 0) { 1452 | this.setHint = this.getHint = this.clearHint = this.clearHintIfInvalid = _.noop; 1453 | } 1454 | this.onSync("cursorchange", this._updateDescendent); 1455 | } 1456 | Input.normalizeQuery = function(str) { 1457 | return _.toStr(str).replace(/^\s*/g, "").replace(/\s{2,}/g, " "); 1458 | }; 1459 | _.mixin(Input.prototype, EventEmitter, { 1460 | _onBlur: function onBlur() { 1461 | this.resetInputValue(); 1462 | this.trigger("blurred"); 1463 | }, 1464 | _onFocus: function onFocus() { 1465 | this.queryWhenFocused = this.query; 1466 | this.trigger("focused"); 1467 | }, 1468 | _onKeydown: function onKeydown($e) { 1469 | var keyName = specialKeyCodeMap[$e.which || $e.keyCode]; 1470 | this._managePreventDefault(keyName, $e); 1471 | if (keyName && this._shouldTrigger(keyName, $e)) { 1472 | this.trigger(keyName + "Keyed", $e); 1473 | } 1474 | }, 1475 | _onInput: function onInput() { 1476 | this._setQuery(this.getInputValue()); 1477 | this.clearHintIfInvalid(); 1478 | this._checkLanguageDirection(); 1479 | }, 1480 | _managePreventDefault: function managePreventDefault(keyName, $e) { 1481 | var preventDefault; 1482 | switch (keyName) { 1483 | case "up": 1484 | case "down": 1485 | preventDefault = !withModifier($e); 1486 | break; 1487 | 1488 | default: 1489 | preventDefault = false; 1490 | } 1491 | preventDefault && $e.preventDefault(); 1492 | }, 1493 | _shouldTrigger: function shouldTrigger(keyName, $e) { 1494 | var trigger; 1495 | switch (keyName) { 1496 | case "tab": 1497 | trigger = !withModifier($e); 1498 | break; 1499 | 1500 | default: 1501 | trigger = true; 1502 | } 1503 | return trigger; 1504 | }, 1505 | _checkLanguageDirection: function checkLanguageDirection() { 1506 | var dir = (this.$input.css("direction") || "ltr").toLowerCase(); 1507 | if (this.dir !== dir) { 1508 | this.dir = dir; 1509 | this.$hint.attr("dir", dir); 1510 | this.trigger("langDirChanged", dir); 1511 | } 1512 | }, 1513 | _setQuery: function setQuery(val, silent) { 1514 | var areEquivalent, hasDifferentWhitespace; 1515 | areEquivalent = areQueriesEquivalent(val, this.query); 1516 | hasDifferentWhitespace = areEquivalent ? this.query.length !== val.length : false; 1517 | this.query = val; 1518 | if (!silent && !areEquivalent) { 1519 | this.trigger("queryChanged", this.query); 1520 | } else if (!silent && hasDifferentWhitespace) { 1521 | this.trigger("whitespaceChanged", this.query); 1522 | } 1523 | }, 1524 | _updateDescendent: function updateDescendent(event, id) { 1525 | this.$input.attr("aria-activedescendant", id); 1526 | }, 1527 | bind: function() { 1528 | var that = this, onBlur, onFocus, onKeydown, onInput; 1529 | onBlur = _.bind(this._onBlur, this); 1530 | onFocus = _.bind(this._onFocus, this); 1531 | onKeydown = _.bind(this._onKeydown, this); 1532 | onInput = _.bind(this._onInput, this); 1533 | this.$input.on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown); 1534 | if (!_.isMsie() || _.isMsie() > 9) { 1535 | this.$input.on("input.tt", onInput); 1536 | } else { 1537 | this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) { 1538 | if (specialKeyCodeMap[$e.which || $e.keyCode]) { 1539 | return; 1540 | } 1541 | _.defer(_.bind(that._onInput, that, $e)); 1542 | }); 1543 | } 1544 | return this; 1545 | }, 1546 | focus: function focus() { 1547 | this.$input.focus(); 1548 | }, 1549 | blur: function blur() { 1550 | this.$input.blur(); 1551 | }, 1552 | getLangDir: function getLangDir() { 1553 | return this.dir; 1554 | }, 1555 | getQuery: function getQuery() { 1556 | return this.query || ""; 1557 | }, 1558 | setQuery: function setQuery(val, silent) { 1559 | this.setInputValue(val); 1560 | this._setQuery(val, silent); 1561 | }, 1562 | hasQueryChangedSinceLastFocus: function hasQueryChangedSinceLastFocus() { 1563 | return this.query !== this.queryWhenFocused; 1564 | }, 1565 | getInputValue: function getInputValue() { 1566 | return this.$input.val(); 1567 | }, 1568 | setInputValue: function setInputValue(value) { 1569 | this.$input.val(value); 1570 | this.clearHintIfInvalid(); 1571 | this._checkLanguageDirection(); 1572 | }, 1573 | resetInputValue: function resetInputValue() { 1574 | this.setInputValue(this.query); 1575 | }, 1576 | getHint: function getHint() { 1577 | return this.$hint.val(); 1578 | }, 1579 | setHint: function setHint(value) { 1580 | this.$hint.val(value); 1581 | }, 1582 | clearHint: function clearHint() { 1583 | this.setHint(""); 1584 | }, 1585 | clearHintIfInvalid: function clearHintIfInvalid() { 1586 | var val, hint, valIsPrefixOfHint, isValid; 1587 | val = this.getInputValue(); 1588 | hint = this.getHint(); 1589 | valIsPrefixOfHint = val !== hint && hint.indexOf(val) === 0; 1590 | isValid = val !== "" && valIsPrefixOfHint && !this.hasOverflow(); 1591 | !isValid && this.clearHint(); 1592 | }, 1593 | hasFocus: function hasFocus() { 1594 | return this.$input.is(":focus"); 1595 | }, 1596 | hasOverflow: function hasOverflow() { 1597 | var constraint = this.$input.width() - 2; 1598 | this.$overflowHelper.text(this.getInputValue()); 1599 | return this.$overflowHelper.width() >= constraint; 1600 | }, 1601 | isCursorAtEnd: function() { 1602 | var valueLength, selectionStart, range; 1603 | valueLength = this.$input.val().length; 1604 | selectionStart = this.$input[0].selectionStart; 1605 | if (_.isNumber(selectionStart)) { 1606 | return selectionStart === valueLength; 1607 | } else if (document.selection) { 1608 | range = document.selection.createRange(); 1609 | range.moveStart("character", -valueLength); 1610 | return valueLength === range.text.length; 1611 | } 1612 | return true; 1613 | }, 1614 | destroy: function destroy() { 1615 | this.$hint.off(".tt"); 1616 | this.$input.off(".tt"); 1617 | this.$overflowHelper.remove(); 1618 | this.$hint = this.$input = this.$overflowHelper = $("
"); 1619 | } 1620 | }); 1621 | return Input; 1622 | function buildOverflowHelper($input) { 1623 | return $('').css({ 1624 | position: "absolute", 1625 | visibility: "hidden", 1626 | whiteSpace: "pre", 1627 | fontFamily: $input.css("font-family"), 1628 | fontSize: $input.css("font-size"), 1629 | fontStyle: $input.css("font-style"), 1630 | fontVariant: $input.css("font-variant"), 1631 | fontWeight: $input.css("font-weight"), 1632 | wordSpacing: $input.css("word-spacing"), 1633 | letterSpacing: $input.css("letter-spacing"), 1634 | textIndent: $input.css("text-indent"), 1635 | textRendering: $input.css("text-rendering"), 1636 | textTransform: $input.css("text-transform") 1637 | }).insertAfter($input); 1638 | } 1639 | function areQueriesEquivalent(a, b) { 1640 | return Input.normalizeQuery(a) === Input.normalizeQuery(b); 1641 | } 1642 | function withModifier($e) { 1643 | return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey; 1644 | } 1645 | }(); 1646 | var Dataset = function() { 1647 | "use strict"; 1648 | var keys, nameGenerator; 1649 | keys = { 1650 | dataset: "tt-selectable-dataset", 1651 | val: "tt-selectable-display", 1652 | obj: "tt-selectable-object" 1653 | }; 1654 | nameGenerator = _.getIdGenerator(); 1655 | function Dataset(o, www) { 1656 | o = o || {}; 1657 | o.templates = o.templates || {}; 1658 | o.templates.notFound = o.templates.notFound || o.templates.empty; 1659 | if (!o.source) { 1660 | $.error("missing source"); 1661 | } 1662 | if (!o.node) { 1663 | $.error("missing node"); 1664 | } 1665 | if (o.name && !isValidName(o.name)) { 1666 | $.error("invalid dataset name: " + o.name); 1667 | } 1668 | www.mixin(this); 1669 | this.highlight = !!o.highlight; 1670 | this.name = _.toStr(o.name || nameGenerator()); 1671 | this.limit = o.limit || 5; 1672 | this.displayFn = getDisplayFn(o.display || o.displayKey); 1673 | this.templates = getTemplates(o.templates, this.displayFn); 1674 | this.source = o.source.__ttAdapter ? o.source.__ttAdapter() : o.source; 1675 | this.async = _.isUndefined(o.async) ? this.source.length > 2 : !!o.async; 1676 | this._resetLastSuggestion(); 1677 | this.$el = $(o.node).attr("role", "presentation").addClass(this.classes.dataset).addClass(this.classes.dataset + "-" + this.name); 1678 | } 1679 | Dataset.extractData = function extractData(el) { 1680 | var $el = $(el); 1681 | if ($el.data(keys.obj)) { 1682 | return { 1683 | dataset: $el.data(keys.dataset) || "", 1684 | val: $el.data(keys.val) || "", 1685 | obj: $el.data(keys.obj) || null 1686 | }; 1687 | } 1688 | return null; 1689 | }; 1690 | _.mixin(Dataset.prototype, EventEmitter, { 1691 | _overwrite: function overwrite(query, suggestions) { 1692 | suggestions = suggestions || []; 1693 | if (suggestions.length) { 1694 | this._renderSuggestions(query, suggestions); 1695 | } else if (this.async && this.templates.pending) { 1696 | this._renderPending(query); 1697 | } else if (!this.async && this.templates.notFound) { 1698 | this._renderNotFound(query); 1699 | } else { 1700 | this._empty(); 1701 | } 1702 | this.trigger("rendered", suggestions, false, this.name); 1703 | }, 1704 | _append: function append(query, suggestions) { 1705 | suggestions = suggestions || []; 1706 | if (suggestions.length && this.$lastSuggestion.length) { 1707 | this._appendSuggestions(query, suggestions); 1708 | } else if (suggestions.length) { 1709 | this._renderSuggestions(query, suggestions); 1710 | } else if (!this.$lastSuggestion.length && this.templates.notFound) { 1711 | this._renderNotFound(query); 1712 | } 1713 | this.trigger("rendered", suggestions, true, this.name); 1714 | }, 1715 | _renderSuggestions: function renderSuggestions(query, suggestions) { 1716 | var $fragment; 1717 | $fragment = this._getSuggestionsFragment(query, suggestions); 1718 | this.$lastSuggestion = $fragment.children().last(); 1719 | this.$el.html($fragment).prepend(this._getHeader(query, suggestions)).append(this._getFooter(query, suggestions)); 1720 | }, 1721 | _appendSuggestions: function appendSuggestions(query, suggestions) { 1722 | var $fragment, $lastSuggestion; 1723 | $fragment = this._getSuggestionsFragment(query, suggestions); 1724 | $lastSuggestion = $fragment.children().last(); 1725 | this.$lastSuggestion.after($fragment); 1726 | this.$lastSuggestion = $lastSuggestion; 1727 | }, 1728 | _renderPending: function renderPending(query) { 1729 | var template = this.templates.pending; 1730 | this._resetLastSuggestion(); 1731 | template && this.$el.html(template({ 1732 | query: query, 1733 | dataset: this.name 1734 | })); 1735 | }, 1736 | _renderNotFound: function renderNotFound(query) { 1737 | var template = this.templates.notFound; 1738 | this._resetLastSuggestion(); 1739 | template && this.$el.html(template({ 1740 | query: query, 1741 | dataset: this.name 1742 | })); 1743 | }, 1744 | _empty: function empty() { 1745 | this.$el.empty(); 1746 | this._resetLastSuggestion(); 1747 | }, 1748 | _getSuggestionsFragment: function getSuggestionsFragment(query, suggestions) { 1749 | var that = this, fragment; 1750 | fragment = document.createDocumentFragment(); 1751 | _.each(suggestions, function getSuggestionNode(suggestion) { 1752 | var $el, context; 1753 | context = that._injectQuery(query, suggestion); 1754 | $el = $(that.templates.suggestion(context)).data(keys.dataset, that.name).data(keys.obj, suggestion).data(keys.val, that.displayFn(suggestion)).addClass(that.classes.suggestion + " " + that.classes.selectable); 1755 | fragment.appendChild($el[0]); 1756 | }); 1757 | this.highlight && highlight({ 1758 | className: this.classes.highlight, 1759 | node: fragment, 1760 | pattern: query 1761 | }); 1762 | return $(fragment); 1763 | }, 1764 | _getFooter: function getFooter(query, suggestions) { 1765 | return this.templates.footer ? this.templates.footer({ 1766 | query: query, 1767 | suggestions: suggestions, 1768 | dataset: this.name 1769 | }) : null; 1770 | }, 1771 | _getHeader: function getHeader(query, suggestions) { 1772 | return this.templates.header ? this.templates.header({ 1773 | query: query, 1774 | suggestions: suggestions, 1775 | dataset: this.name 1776 | }) : null; 1777 | }, 1778 | _resetLastSuggestion: function resetLastSuggestion() { 1779 | this.$lastSuggestion = $(); 1780 | }, 1781 | _injectQuery: function injectQuery(query, obj) { 1782 | return _.isObject(obj) ? _.mixin({ 1783 | _query: query 1784 | }, obj) : obj; 1785 | }, 1786 | update: function update(query) { 1787 | var that = this, canceled = false, syncCalled = false, rendered = 0; 1788 | this.cancel(); 1789 | this.cancel = function cancel() { 1790 | canceled = true; 1791 | that.cancel = $.noop; 1792 | that.async && that.trigger("asyncCanceled", query, that.name); 1793 | }; 1794 | this.source(query, sync, async); 1795 | !syncCalled && sync([]); 1796 | function sync(suggestions) { 1797 | if (syncCalled) { 1798 | return; 1799 | } 1800 | syncCalled = true; 1801 | suggestions = (suggestions || []).slice(0, that.limit); 1802 | rendered = suggestions.length; 1803 | that._overwrite(query, suggestions); 1804 | if (rendered < that.limit && that.async) { 1805 | that.trigger("asyncRequested", query, that.name); 1806 | } 1807 | } 1808 | function async(suggestions) { 1809 | suggestions = suggestions || []; 1810 | if (!canceled && rendered < that.limit) { 1811 | that.cancel = $.noop; 1812 | var idx = Math.abs(rendered - that.limit); 1813 | rendered += idx; 1814 | that._append(query, suggestions.slice(0, idx)); 1815 | that.async && that.trigger("asyncReceived", query, that.name); 1816 | } 1817 | } 1818 | }, 1819 | cancel: $.noop, 1820 | clear: function clear() { 1821 | this._empty(); 1822 | this.cancel(); 1823 | this.trigger("cleared"); 1824 | }, 1825 | isEmpty: function isEmpty() { 1826 | return this.$el.is(":empty"); 1827 | }, 1828 | destroy: function destroy() { 1829 | this.$el = $("
"); 1830 | } 1831 | }); 1832 | return Dataset; 1833 | function getDisplayFn(display) { 1834 | display = display || _.stringify; 1835 | return _.isFunction(display) ? display : displayFn; 1836 | function displayFn(obj) { 1837 | return obj[display]; 1838 | } 1839 | } 1840 | function getTemplates(templates, displayFn) { 1841 | return { 1842 | notFound: templates.notFound && _.templatify(templates.notFound), 1843 | pending: templates.pending && _.templatify(templates.pending), 1844 | header: templates.header && _.templatify(templates.header), 1845 | footer: templates.footer && _.templatify(templates.footer), 1846 | suggestion: templates.suggestion || suggestionTemplate 1847 | }; 1848 | function suggestionTemplate(context) { 1849 | return $('
').attr("id", _.guid()).text(displayFn(context)); 1850 | } 1851 | } 1852 | function isValidName(str) { 1853 | return /^[_a-zA-Z0-9-]+$/.test(str); 1854 | } 1855 | }(); 1856 | var Menu = function() { 1857 | "use strict"; 1858 | function Menu(o, www) { 1859 | var that = this; 1860 | o = o || {}; 1861 | if (!o.node) { 1862 | $.error("node is required"); 1863 | } 1864 | www.mixin(this); 1865 | this.$node = $(o.node); 1866 | this.query = null; 1867 | this.datasets = _.map(o.datasets, initializeDataset); 1868 | function initializeDataset(oDataset) { 1869 | var node = that.$node.find(oDataset.node).first(); 1870 | oDataset.node = node.length ? node : $("
").appendTo(that.$node); 1871 | return new Dataset(oDataset, www); 1872 | } 1873 | } 1874 | _.mixin(Menu.prototype, EventEmitter, { 1875 | _onSelectableClick: function onSelectableClick($e) { 1876 | this.trigger("selectableClicked", $($e.currentTarget)); 1877 | }, 1878 | _onRendered: function onRendered(type, dataset, suggestions, async) { 1879 | this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); 1880 | this.trigger("datasetRendered", dataset, suggestions, async); 1881 | }, 1882 | _onCleared: function onCleared() { 1883 | this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); 1884 | this.trigger("datasetCleared"); 1885 | }, 1886 | _propagate: function propagate() { 1887 | this.trigger.apply(this, arguments); 1888 | }, 1889 | _allDatasetsEmpty: function allDatasetsEmpty() { 1890 | return _.every(this.datasets, _.bind(function isDatasetEmpty(dataset) { 1891 | var isEmpty = dataset.isEmpty(); 1892 | this.$node.attr("aria-expanded", !isEmpty); 1893 | return isEmpty; 1894 | }, this)); 1895 | }, 1896 | _getSelectables: function getSelectables() { 1897 | return this.$node.find(this.selectors.selectable); 1898 | }, 1899 | _removeCursor: function _removeCursor() { 1900 | var $selectable = this.getActiveSelectable(); 1901 | $selectable && $selectable.removeClass(this.classes.cursor); 1902 | }, 1903 | _ensureVisible: function ensureVisible($el) { 1904 | var elTop, elBottom, nodeScrollTop, nodeHeight; 1905 | elTop = $el.position().top; 1906 | elBottom = elTop + $el.outerHeight(true); 1907 | nodeScrollTop = this.$node.scrollTop(); 1908 | nodeHeight = this.$node.height() + parseInt(this.$node.css("paddingTop"), 10) + parseInt(this.$node.css("paddingBottom"), 10); 1909 | if (elTop < 0) { 1910 | this.$node.scrollTop(nodeScrollTop + elTop); 1911 | } else if (nodeHeight < elBottom) { 1912 | this.$node.scrollTop(nodeScrollTop + (elBottom - nodeHeight)); 1913 | } 1914 | }, 1915 | bind: function() { 1916 | var that = this, onSelectableClick; 1917 | onSelectableClick = _.bind(this._onSelectableClick, this); 1918 | this.$node.on("click.tt", this.selectors.selectable, onSelectableClick); 1919 | this.$node.on("mouseover", this.selectors.selectable, function() { 1920 | that.setCursor($(this)); 1921 | }); 1922 | this.$node.on("mouseleave", function() { 1923 | that._removeCursor(); 1924 | }); 1925 | _.each(this.datasets, function(dataset) { 1926 | dataset.onSync("asyncRequested", that._propagate, that).onSync("asyncCanceled", that._propagate, that).onSync("asyncReceived", that._propagate, that).onSync("rendered", that._onRendered, that).onSync("cleared", that._onCleared, that); 1927 | }); 1928 | return this; 1929 | }, 1930 | isOpen: function isOpen() { 1931 | return this.$node.hasClass(this.classes.open); 1932 | }, 1933 | open: function open() { 1934 | this.$node.scrollTop(0); 1935 | this.$node.addClass(this.classes.open); 1936 | }, 1937 | close: function close() { 1938 | this.$node.attr("aria-expanded", false); 1939 | this.$node.removeClass(this.classes.open); 1940 | this._removeCursor(); 1941 | }, 1942 | setLanguageDirection: function setLanguageDirection(dir) { 1943 | this.$node.attr("dir", dir); 1944 | }, 1945 | selectableRelativeToCursor: function selectableRelativeToCursor(delta) { 1946 | var $selectables, $oldCursor, oldIndex, newIndex; 1947 | $oldCursor = this.getActiveSelectable(); 1948 | $selectables = this._getSelectables(); 1949 | oldIndex = $oldCursor ? $selectables.index($oldCursor) : -1; 1950 | newIndex = oldIndex + delta; 1951 | newIndex = (newIndex + 1) % ($selectables.length + 1) - 1; 1952 | newIndex = newIndex < -1 ? $selectables.length - 1 : newIndex; 1953 | return newIndex === -1 ? null : $selectables.eq(newIndex); 1954 | }, 1955 | setCursor: function setCursor($selectable) { 1956 | this._removeCursor(); 1957 | if ($selectable = $selectable && $selectable.first()) { 1958 | $selectable.addClass(this.classes.cursor); 1959 | this._ensureVisible($selectable); 1960 | } 1961 | }, 1962 | getSelectableData: function getSelectableData($el) { 1963 | return $el && $el.length ? Dataset.extractData($el) : null; 1964 | }, 1965 | getActiveSelectable: function getActiveSelectable() { 1966 | var $selectable = this._getSelectables().filter(this.selectors.cursor).first(); 1967 | return $selectable.length ? $selectable : null; 1968 | }, 1969 | getTopSelectable: function getTopSelectable() { 1970 | var $selectable = this._getSelectables().first(); 1971 | return $selectable.length ? $selectable : null; 1972 | }, 1973 | update: function update(query) { 1974 | var isValidUpdate = query !== this.query; 1975 | if (isValidUpdate) { 1976 | this.query = query; 1977 | _.each(this.datasets, updateDataset); 1978 | } 1979 | return isValidUpdate; 1980 | function updateDataset(dataset) { 1981 | dataset.update(query); 1982 | } 1983 | }, 1984 | empty: function empty() { 1985 | _.each(this.datasets, clearDataset); 1986 | this.query = null; 1987 | this.$node.addClass(this.classes.empty); 1988 | function clearDataset(dataset) { 1989 | dataset.clear(); 1990 | } 1991 | }, 1992 | destroy: function destroy() { 1993 | this.$node.off(".tt"); 1994 | this.$node = $("
"); 1995 | _.each(this.datasets, destroyDataset); 1996 | function destroyDataset(dataset) { 1997 | dataset.destroy(); 1998 | } 1999 | } 2000 | }); 2001 | return Menu; 2002 | }(); 2003 | var Status = function() { 2004 | "use strict"; 2005 | function Status(options) { 2006 | this.$el = $("", { 2007 | role: "status", 2008 | "aria-live": "polite" 2009 | }).css({ 2010 | position: "absolute", 2011 | padding: "0", 2012 | border: "0", 2013 | height: "1px", 2014 | width: "1px", 2015 | "margin-bottom": "-1px", 2016 | "margin-right": "-1px", 2017 | overflow: "hidden", 2018 | clip: "rect(0 0 0 0)", 2019 | "white-space": "nowrap" 2020 | }); 2021 | options.$input.after(this.$el); 2022 | _.each(options.menu.datasets, _.bind(function(dataset) { 2023 | if (dataset.onSync) { 2024 | dataset.onSync("rendered", _.bind(this.update, this)); 2025 | dataset.onSync("cleared", _.bind(this.cleared, this)); 2026 | } 2027 | }, this)); 2028 | } 2029 | _.mixin(Status.prototype, { 2030 | update: function update(event, suggestions) { 2031 | var length = suggestions.length; 2032 | var words; 2033 | if (length === 1) { 2034 | words = { 2035 | result: "result", 2036 | is: "is" 2037 | }; 2038 | } else { 2039 | words = { 2040 | result: "results", 2041 | is: "are" 2042 | }; 2043 | } 2044 | this.$el.text(length + " " + words.result + " " + words.is + " available, use up and down arrow keys to navigate."); 2045 | }, 2046 | cleared: function() { 2047 | this.$el.text(""); 2048 | } 2049 | }); 2050 | return Status; 2051 | }(); 2052 | var DefaultMenu = function() { 2053 | "use strict"; 2054 | var s = Menu.prototype; 2055 | function DefaultMenu() { 2056 | Menu.apply(this, [].slice.call(arguments, 0)); 2057 | } 2058 | _.mixin(DefaultMenu.prototype, Menu.prototype, { 2059 | open: function open() { 2060 | !this._allDatasetsEmpty() && this._show(); 2061 | return s.open.apply(this, [].slice.call(arguments, 0)); 2062 | }, 2063 | close: function close() { 2064 | this._hide(); 2065 | return s.close.apply(this, [].slice.call(arguments, 0)); 2066 | }, 2067 | _onRendered: function onRendered() { 2068 | if (this._allDatasetsEmpty()) { 2069 | this._hide(); 2070 | } else { 2071 | this.isOpen() && this._show(); 2072 | } 2073 | return s._onRendered.apply(this, [].slice.call(arguments, 0)); 2074 | }, 2075 | _onCleared: function onCleared() { 2076 | if (this._allDatasetsEmpty()) { 2077 | this._hide(); 2078 | } else { 2079 | this.isOpen() && this._show(); 2080 | } 2081 | return s._onCleared.apply(this, [].slice.call(arguments, 0)); 2082 | }, 2083 | setLanguageDirection: function setLanguageDirection(dir) { 2084 | this.$node.css(dir === "ltr" ? this.css.ltr : this.css.rtl); 2085 | return s.setLanguageDirection.apply(this, [].slice.call(arguments, 0)); 2086 | }, 2087 | _hide: function hide() { 2088 | this.$node.hide(); 2089 | }, 2090 | _show: function show() { 2091 | this.$node.css("display", "flex"); 2092 | } 2093 | }); 2094 | return DefaultMenu; 2095 | }(); 2096 | var Typeahead = function() { 2097 | "use strict"; 2098 | function Typeahead(o, www) { 2099 | var onFocused, onBlurred, onEnterKeyed, onTabKeyed, onEscKeyed, onUpKeyed, onDownKeyed, onLeftKeyed, onRightKeyed, onQueryChanged, onWhitespaceChanged; 2100 | o = o || {}; 2101 | if (!o.input) { 2102 | $.error("missing input"); 2103 | } 2104 | if (!o.menu) { 2105 | $.error("missing menu"); 2106 | } 2107 | if (!o.eventBus) { 2108 | $.error("missing event bus"); 2109 | } 2110 | www.mixin(this); 2111 | this.eventBus = o.eventBus; 2112 | this.minLength = _.isNumber(o.minLength) ? o.minLength : 1; 2113 | this.input = o.input; 2114 | this.menu = o.menu; 2115 | this.enabled = true; 2116 | this.autoselect = !!o.autoselect; 2117 | this.active = false; 2118 | this.input.hasFocus() && this.activate(); 2119 | this.dir = this.input.getLangDir(); 2120 | this._hacks(); 2121 | this.menu.bind().onSync("selectableClicked", this._onSelectableClicked, this).onSync("asyncRequested", this._onAsyncRequested, this).onSync("asyncCanceled", this._onAsyncCanceled, this).onSync("asyncReceived", this._onAsyncReceived, this).onSync("datasetRendered", this._onDatasetRendered, this).onSync("datasetCleared", this._onDatasetCleared, this); 2122 | onFocused = c(this, "activate", "open", "_onFocused"); 2123 | onBlurred = c(this, "deactivate", "_onBlurred"); 2124 | onEnterKeyed = c(this, "isActive", "isOpen", "_onEnterKeyed"); 2125 | onTabKeyed = c(this, "isActive", "isOpen", "_onTabKeyed"); 2126 | onEscKeyed = c(this, "isActive", "_onEscKeyed"); 2127 | onUpKeyed = c(this, "isActive", "open", "_onUpKeyed"); 2128 | onDownKeyed = c(this, "isActive", "open", "_onDownKeyed"); 2129 | onLeftKeyed = c(this, "isActive", "isOpen", "_onLeftKeyed"); 2130 | onRightKeyed = c(this, "isActive", "isOpen", "_onRightKeyed"); 2131 | onQueryChanged = c(this, "_openIfActive", "_onQueryChanged"); 2132 | onWhitespaceChanged = c(this, "_openIfActive", "_onWhitespaceChanged"); 2133 | this.input.bind().onSync("focused", onFocused, this).onSync("blurred", onBlurred, this).onSync("enterKeyed", onEnterKeyed, this).onSync("tabKeyed", onTabKeyed, this).onSync("escKeyed", onEscKeyed, this).onSync("upKeyed", onUpKeyed, this).onSync("downKeyed", onDownKeyed, this).onSync("leftKeyed", onLeftKeyed, this).onSync("rightKeyed", onRightKeyed, this).onSync("queryChanged", onQueryChanged, this).onSync("whitespaceChanged", onWhitespaceChanged, this).onSync("langDirChanged", this._onLangDirChanged, this); 2134 | } 2135 | _.mixin(Typeahead.prototype, { 2136 | _hacks: function hacks() { 2137 | var $input, $menu; 2138 | $input = this.input.$input || $("
"); 2139 | $menu = this.menu.$node || $("
"); 2140 | $input.on("blur.tt", function($e) { 2141 | var active, isActive, hasActive; 2142 | active = document.activeElement; 2143 | isActive = $menu.is(active); 2144 | hasActive = $menu.has(active).length > 0; 2145 | if (_.isMsie() && (isActive || hasActive)) { 2146 | $e.preventDefault(); 2147 | $e.stopImmediatePropagation(); 2148 | _.defer(function() { 2149 | $input.focus(); 2150 | }); 2151 | } 2152 | }); 2153 | $menu.on("mousedown.tt", function($e) { 2154 | $e.preventDefault(); 2155 | }); 2156 | }, 2157 | _onSelectableClicked: function onSelectableClicked(type, $el) { 2158 | this.select($el); 2159 | }, 2160 | _onDatasetCleared: function onDatasetCleared() { 2161 | this._updateHint(); 2162 | }, 2163 | _onDatasetRendered: function onDatasetRendered(type, suggestions, async, dataset) { 2164 | this._updateHint(); 2165 | if (this.autoselect) { 2166 | var cursorClass = this.selectors.cursor.substr(1); 2167 | this.menu.$node.find(this.selectors.suggestion).first().addClass(cursorClass); 2168 | } 2169 | this.eventBus.trigger("render", suggestions, async, dataset); 2170 | }, 2171 | _onAsyncRequested: function onAsyncRequested(type, dataset, query) { 2172 | this.eventBus.trigger("asyncrequest", query, dataset); 2173 | }, 2174 | _onAsyncCanceled: function onAsyncCanceled(type, dataset, query) { 2175 | this.eventBus.trigger("asynccancel", query, dataset); 2176 | }, 2177 | _onAsyncReceived: function onAsyncReceived(type, dataset, query) { 2178 | this.eventBus.trigger("asyncreceive", query, dataset); 2179 | }, 2180 | _onFocused: function onFocused() { 2181 | this._minLengthMet() && this.menu.update(this.input.getQuery()); 2182 | }, 2183 | _onBlurred: function onBlurred() { 2184 | if (this.input.hasQueryChangedSinceLastFocus()) { 2185 | this.eventBus.trigger("change", this.input.getQuery()); 2186 | } 2187 | }, 2188 | _onEnterKeyed: function onEnterKeyed(type, $e) { 2189 | var $selectable; 2190 | if ($selectable = this.menu.getActiveSelectable()) { 2191 | if (this.select($selectable)) { 2192 | $e.preventDefault(); 2193 | $e.stopPropagation(); 2194 | } 2195 | } else if (this.autoselect) { 2196 | if (this.select(this.menu.getTopSelectable())) { 2197 | $e.preventDefault(); 2198 | $e.stopPropagation(); 2199 | } 2200 | } 2201 | }, 2202 | _onTabKeyed: function onTabKeyed(type, $e) { 2203 | var $selectable; 2204 | if ($selectable = this.menu.getActiveSelectable()) { 2205 | this.select($selectable) && $e.preventDefault(); 2206 | } else if ($selectable = this.menu.getTopSelectable()) { 2207 | this.autocomplete($selectable) && $e.preventDefault(); 2208 | } 2209 | }, 2210 | _onEscKeyed: function onEscKeyed() { 2211 | this.close(); 2212 | }, 2213 | _onUpKeyed: function onUpKeyed() { 2214 | this.moveCursor(-1); 2215 | }, 2216 | _onDownKeyed: function onDownKeyed() { 2217 | this.moveCursor(+1); 2218 | }, 2219 | _onLeftKeyed: function onLeftKeyed() { 2220 | if (this.dir === "rtl" && this.input.isCursorAtEnd()) { 2221 | this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable()); 2222 | } 2223 | }, 2224 | _onRightKeyed: function onRightKeyed() { 2225 | if (this.dir === "ltr" && this.input.isCursorAtEnd()) { 2226 | this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable()); 2227 | } 2228 | }, 2229 | _onQueryChanged: function onQueryChanged(e, query) { 2230 | this._minLengthMet(query) ? this.menu.update(query) : this.menu.empty(); 2231 | }, 2232 | _onWhitespaceChanged: function onWhitespaceChanged() { 2233 | this._updateHint(); 2234 | }, 2235 | _onLangDirChanged: function onLangDirChanged(e, dir) { 2236 | if (this.dir !== dir) { 2237 | this.dir = dir; 2238 | this.menu.setLanguageDirection(dir); 2239 | } 2240 | }, 2241 | _openIfActive: function openIfActive() { 2242 | this.isActive() && this.open(); 2243 | }, 2244 | _minLengthMet: function minLengthMet(query) { 2245 | query = _.isString(query) ? query : this.input.getQuery() || ""; 2246 | return query.length >= this.minLength; 2247 | }, 2248 | _updateHint: function updateHint() { 2249 | var $selectable, data, val, query, escapedQuery, frontMatchRegEx, match; 2250 | $selectable = this.menu.getTopSelectable(); 2251 | data = this.menu.getSelectableData($selectable); 2252 | val = this.input.getInputValue(); 2253 | if (data && !_.isBlankString(val) && !this.input.hasOverflow()) { 2254 | query = Input.normalizeQuery(val); 2255 | escapedQuery = _.escapeRegExChars(query); 2256 | frontMatchRegEx = new RegExp("^(?:" + escapedQuery + ")(.+$)", "i"); 2257 | match = frontMatchRegEx.exec(data.val); 2258 | match && this.input.setHint(val + match[1]); 2259 | } else { 2260 | this.input.clearHint(); 2261 | } 2262 | }, 2263 | isEnabled: function isEnabled() { 2264 | return this.enabled; 2265 | }, 2266 | enable: function enable() { 2267 | this.enabled = true; 2268 | }, 2269 | disable: function disable() { 2270 | this.enabled = false; 2271 | }, 2272 | isActive: function isActive() { 2273 | return this.active; 2274 | }, 2275 | activate: function activate() { 2276 | if (this.isActive()) { 2277 | return true; 2278 | } else if (!this.isEnabled() || this.eventBus.before("active")) { 2279 | return false; 2280 | } else { 2281 | this.active = true; 2282 | this.eventBus.trigger("active"); 2283 | return true; 2284 | } 2285 | }, 2286 | deactivate: function deactivate() { 2287 | if (!this.isActive()) { 2288 | return true; 2289 | } else if (this.eventBus.before("idle")) { 2290 | return false; 2291 | } else { 2292 | this.active = false; 2293 | this.close(); 2294 | this.eventBus.trigger("idle"); 2295 | return true; 2296 | } 2297 | }, 2298 | isOpen: function isOpen() { 2299 | return this.menu.isOpen(); 2300 | }, 2301 | open: function open() { 2302 | if (!this.isOpen() && !this.eventBus.before("open")) { 2303 | this.menu.open(); 2304 | this._updateHint(); 2305 | this.eventBus.trigger("open"); 2306 | } 2307 | return this.isOpen(); 2308 | }, 2309 | close: function close() { 2310 | if (this.isOpen() && !this.eventBus.before("close")) { 2311 | this.menu.close(); 2312 | this.input.clearHint(); 2313 | this.input.resetInputValue(); 2314 | this.eventBus.trigger("close"); 2315 | } 2316 | return !this.isOpen(); 2317 | }, 2318 | setVal: function setVal(val) { 2319 | this.input.setQuery(_.toStr(val)); 2320 | }, 2321 | getVal: function getVal() { 2322 | return this.input.getQuery(); 2323 | }, 2324 | select: function select($selectable) { 2325 | var data = this.menu.getSelectableData($selectable); 2326 | if (data && !this.eventBus.before("select", data.obj, data.dataset)) { 2327 | this.input.setQuery(data.val, true); 2328 | this.eventBus.trigger("select", data.obj, data.dataset); 2329 | this.close(); 2330 | return true; 2331 | } 2332 | return false; 2333 | }, 2334 | autocomplete: function autocomplete($selectable) { 2335 | var query, data, isValid; 2336 | query = this.input.getQuery(); 2337 | data = this.menu.getSelectableData($selectable); 2338 | isValid = data && query !== data.val; 2339 | if (isValid && !this.eventBus.before("autocomplete", data.obj, data.dataset)) { 2340 | this.input.setQuery(data.val); 2341 | this.eventBus.trigger("autocomplete", data.obj, data.dataset); 2342 | return true; 2343 | } 2344 | return false; 2345 | }, 2346 | moveCursor: function moveCursor(delta) { 2347 | var query, $candidate, data, suggestion, datasetName, cancelMove, id; 2348 | query = this.input.getQuery(); 2349 | $candidate = this.menu.selectableRelativeToCursor(delta); 2350 | data = this.menu.getSelectableData($candidate); 2351 | suggestion = data ? data.obj : null; 2352 | datasetName = data ? data.dataset : null; 2353 | id = $candidate ? $candidate.attr("id") : null; 2354 | this.input.trigger("cursorchange", id); 2355 | cancelMove = this._minLengthMet() && this.menu.update(query); 2356 | if (!cancelMove && !this.eventBus.before("cursorchange", suggestion, datasetName)) { 2357 | this.menu.setCursor($candidate); 2358 | if (data) { 2359 | this.input.setInputValue(data.val); 2360 | } else { 2361 | this.input.resetInputValue(); 2362 | this._updateHint(); 2363 | } 2364 | this.eventBus.trigger("cursorchange", suggestion, datasetName); 2365 | return true; 2366 | } 2367 | return false; 2368 | }, 2369 | destroy: function destroy() { 2370 | this.input.destroy(); 2371 | this.menu.destroy(); 2372 | } 2373 | }); 2374 | return Typeahead; 2375 | function c(ctx) { 2376 | var methods = [].slice.call(arguments, 1); 2377 | return function() { 2378 | var args = [].slice.call(arguments); 2379 | _.each(methods, function(method) { 2380 | return ctx[method].apply(ctx, args); 2381 | }); 2382 | }; 2383 | } 2384 | }(); 2385 | (function() { 2386 | "use strict"; 2387 | var old, keys, methods; 2388 | old = $.fn.typeahead; 2389 | keys = { 2390 | www: "tt-www", 2391 | attrs: "tt-attrs", 2392 | typeahead: "tt-typeahead" 2393 | }; 2394 | methods = { 2395 | initialize: function initialize(o, datasets) { 2396 | var www; 2397 | datasets = _.isArray(datasets) ? datasets : [].slice.call(arguments, 1); 2398 | o = o || {}; 2399 | www = WWW(o.classNames); 2400 | return this.each(attach); 2401 | function attach() { 2402 | var $input, $wrapper, $hint, $menu, defaultHint, defaultMenu, eventBus, input, menu, status, typeahead, MenuConstructor; 2403 | _.each(datasets, function(d) { 2404 | d.highlight = !!o.highlight; 2405 | }); 2406 | $input = $(this); 2407 | $wrapper = $(www.html.wrapper); 2408 | $hint = $elOrNull(o.hint); 2409 | $menu = $elOrNull(o.menu); 2410 | defaultHint = o.hint !== false && !$hint; 2411 | defaultMenu = o.menu !== false && !$menu; 2412 | defaultHint && ($hint = buildHintFromInput($input, www)); 2413 | defaultMenu && ($menu = $(www.html.menu).css(www.css.menu)); 2414 | $hint && $hint.val(""); 2415 | $input = prepInput($input, www); 2416 | if (defaultHint || defaultMenu) { 2417 | $wrapper.css(www.css.wrapper); 2418 | $input.css(defaultHint ? www.css.input : www.css.inputWithNoHint); 2419 | $input.wrap($wrapper).parent().prepend(defaultHint ? $hint : null).append(defaultMenu ? $menu : null); 2420 | } 2421 | MenuConstructor = defaultMenu ? DefaultMenu : Menu; 2422 | eventBus = new EventBus({ 2423 | el: $input 2424 | }); 2425 | input = new Input({ 2426 | hint: $hint, 2427 | input: $input 2428 | }, www); 2429 | menu = new MenuConstructor({ 2430 | node: $menu, 2431 | datasets: datasets 2432 | }, www); 2433 | status = new Status({ 2434 | $input: $input, 2435 | menu: menu 2436 | }); 2437 | typeahead = new Typeahead({ 2438 | input: input, 2439 | menu: menu, 2440 | eventBus: eventBus, 2441 | minLength: o.minLength, 2442 | autoselect: o.autoselect 2443 | }, www); 2444 | $input.data(keys.www, www); 2445 | $input.data(keys.typeahead, typeahead); 2446 | } 2447 | }, 2448 | isEnabled: function isEnabled() { 2449 | var enabled; 2450 | ttEach(this.first(), function(t) { 2451 | enabled = t.isEnabled(); 2452 | }); 2453 | return enabled; 2454 | }, 2455 | enable: function enable() { 2456 | ttEach(this, function(t) { 2457 | t.enable(); 2458 | }); 2459 | return this; 2460 | }, 2461 | disable: function disable() { 2462 | ttEach(this, function(t) { 2463 | t.disable(); 2464 | }); 2465 | return this; 2466 | }, 2467 | isActive: function isActive() { 2468 | var active; 2469 | ttEach(this.first(), function(t) { 2470 | active = t.isActive(); 2471 | }); 2472 | return active; 2473 | }, 2474 | activate: function activate() { 2475 | ttEach(this, function(t) { 2476 | t.activate(); 2477 | }); 2478 | return this; 2479 | }, 2480 | deactivate: function deactivate() { 2481 | ttEach(this, function(t) { 2482 | t.deactivate(); 2483 | }); 2484 | return this; 2485 | }, 2486 | isOpen: function isOpen() { 2487 | var open; 2488 | ttEach(this.first(), function(t) { 2489 | open = t.isOpen(); 2490 | }); 2491 | return open; 2492 | }, 2493 | open: function open() { 2494 | ttEach(this, function(t) { 2495 | t.open(); 2496 | }); 2497 | return this; 2498 | }, 2499 | close: function close() { 2500 | ttEach(this, function(t) { 2501 | t.close(); 2502 | }); 2503 | return this; 2504 | }, 2505 | select: function select(el) { 2506 | var success = false, $el = $(el); 2507 | ttEach(this.first(), function(t) { 2508 | success = t.select($el); 2509 | }); 2510 | return success; 2511 | }, 2512 | autocomplete: function autocomplete(el) { 2513 | var success = false, $el = $(el); 2514 | ttEach(this.first(), function(t) { 2515 | success = t.autocomplete($el); 2516 | }); 2517 | return success; 2518 | }, 2519 | moveCursor: function moveCursoe(delta) { 2520 | var success = false; 2521 | ttEach(this.first(), function(t) { 2522 | success = t.moveCursor(delta); 2523 | }); 2524 | return success; 2525 | }, 2526 | val: function val(newVal) { 2527 | var query; 2528 | if (!arguments.length) { 2529 | ttEach(this.first(), function(t) { 2530 | query = t.getVal(); 2531 | }); 2532 | return query; 2533 | } else { 2534 | ttEach(this, function(t) { 2535 | t.setVal(_.toStr(newVal)); 2536 | }); 2537 | return this; 2538 | } 2539 | }, 2540 | destroy: function destroy() { 2541 | ttEach(this, function(typeahead, $input) { 2542 | revert($input); 2543 | typeahead.destroy(); 2544 | }); 2545 | return this; 2546 | } 2547 | }; 2548 | $.fn.typeahead = function(method) { 2549 | if (methods[method]) { 2550 | return methods[method].apply(this, [].slice.call(arguments, 1)); 2551 | } else { 2552 | return methods.initialize.apply(this, arguments); 2553 | } 2554 | }; 2555 | $.fn.typeahead.noConflict = function noConflict() { 2556 | $.fn.typeahead = old; 2557 | return this; 2558 | }; 2559 | function ttEach($els, fn) { 2560 | $els.each(function() { 2561 | var $input = $(this), typeahead; 2562 | (typeahead = $input.data(keys.typeahead)) && fn(typeahead, $input); 2563 | }); 2564 | } 2565 | function buildHintFromInput($input, www) { 2566 | return $input.clone().addClass(www.classes.hint).removeData().css(www.css.hint).css(getBackgroundStyles($input)).prop({ 2567 | readonly: true, 2568 | required: false 2569 | }).removeAttr("id name placeholder").removeClass("required").attr({ 2570 | spellcheck: "false", 2571 | tabindex: -1 2572 | }); 2573 | } 2574 | function prepInput($input, www) { 2575 | $input.data(keys.attrs, { 2576 | dir: $input.attr("dir"), 2577 | autocomplete: $input.attr("autocomplete"), 2578 | spellcheck: $input.attr("spellcheck"), 2579 | style: $input.attr("style") 2580 | }); 2581 | $input.addClass(www.classes.input).attr({ 2582 | spellcheck: false 2583 | }); 2584 | try { 2585 | !$input.attr("dir") && $input.attr("dir", "auto"); 2586 | } catch (e) {} 2587 | return $input; 2588 | } 2589 | function getBackgroundStyles($el) { 2590 | return { 2591 | backgroundAttachment: $el.css("background-attachment"), 2592 | backgroundClip: $el.css("background-clip"), 2593 | backgroundColor: $el.css("background-color"), 2594 | backgroundImage: $el.css("background-image"), 2595 | backgroundOrigin: $el.css("background-origin"), 2596 | backgroundPosition: $el.css("background-position"), 2597 | backgroundRepeat: $el.css("background-repeat"), 2598 | backgroundSize: $el.css("background-size") 2599 | }; 2600 | } 2601 | function revert($input) { 2602 | var www, $wrapper; 2603 | www = $input.data(keys.www); 2604 | $wrapper = $input.parent().filter(www.selectors.wrapper); 2605 | _.each($input.data(keys.attrs), function(val, key) { 2606 | _.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val); 2607 | }); 2608 | $input.removeData(keys.typeahead).removeData(keys.www).removeData(keys.attr).removeClass(www.classes.input); 2609 | if ($wrapper.length) { 2610 | $input.detach().insertAfter($wrapper); 2611 | $wrapper.remove(); 2612 | } 2613 | } 2614 | function $elOrNull(obj) { 2615 | var isValid, $el; 2616 | isValid = _.isJQuery(obj) || _.isElement(obj); 2617 | $el = isValid ? $(obj).first() : []; 2618 | return $el.length ? $el : null; 2619 | } 2620 | })(); 2621 | }); -------------------------------------------------------------------------------- /assets/stylesheets/elasticsearch-base.scss: -------------------------------------------------------------------------------- 1 | /* make changes to discourse styling outside the affected widgets 2 | only when the plugin has been enabled */ 3 | body.elasticsearch-enabled { 4 | 5 | /* don't let topic titles run into the search bar */ 6 | .extra-info-wrapper { 7 | max-width: 300px; 8 | } 9 | 10 | /* hide the default search icon */ 11 | header .icons > li:nth-child(2){ 12 | display: none; 13 | } 14 | 15 | .es-holder { 16 | padding: 3px 15px 0 0; 17 | text-align: left; 18 | } 19 | 20 | .es-holder .es-input { 21 | color: #919191; 22 | font-family: Open Sans, Arial, sans-serif; 23 | border: solid 1px rgba(137,149,199,0.2); 24 | border-radius: 6px; 25 | font-size: 13px; 26 | line-height: 13px; 27 | padding: 8px 10px 8px 35px; 28 | height: auto; 29 | width: 250px; 30 | outline: 0; 31 | background-image: url("data:image/svg+xml;utf8,"); 32 | background-size: 14px 14px; 33 | background-repeat: no-repeat; 34 | background-position: 10px center; 35 | 36 | } 37 | 38 | @media (max-width: 768px) { 39 | .es-holder .es-input { 40 | display: none; 41 | } 42 | } 43 | 44 | .es-autocomplete .es-dropdown-menu { 45 | left: -295px !important; 46 | margin-top: 8px; 47 | border-radius: 3px; 48 | box-shadow: 0 1px 0 0 rgba(0,0,0,0.2),0 2px 3px 0 rgba(0,0,0,0.1); 49 | } 50 | 51 | .empty-message { 52 | background-color: #fff; 53 | padding: 5px; 54 | border: 1px solid #e3e5ec; 55 | border-radius: 5px; 56 | width: 285px; 57 | right: 0; 58 | position: absolute; 59 | } 60 | .es-holder form{ 61 | margin: 0!important; 62 | } 63 | } -------------------------------------------------------------------------------- /assets/stylesheets/elasticsearch-layout.scss: -------------------------------------------------------------------------------- 1 | .tt-dataset { 2 | /*width: $es-dropdown-width;*/ 3 | .tt-dataset-users-tags, .tt-dataset-posts { 4 | display: table-cell; 5 | vertical-align: top; 6 | } 7 | .tt-dataset-users-tags { 8 | width: $es-dropdown-right-panel-width; 9 | } 10 | .tt-dataset-posts { 11 | width: $es-dropdown-left-panel-width; 12 | } 13 | } 14 | 15 | .tt-dataset { 16 | border-right: 1px solid $es-border-color; 17 | border-bottom: 1px solid $es-border-color; 18 | background-color: #fff; 19 | .tt-dataset-posts { 20 | background-color: $es-background-color; 21 | } 22 | .tt-dataset-users-tags { 23 | border-left: 1px solid $es-border-color; 24 | background-color: #f8f9fc; 25 | } 26 | } 27 | .tt-dataset:first-child{ 28 | border-left: 1px solid $es-border-color; 29 | flex-grow: 2; 30 | } 31 | 32 | /* suggestions */ 33 | .hit-post-category-name:hover { 34 | text-decoration: underline; 35 | } 36 | 37 | .hit-post-tag:hover { 38 | text-decoration: underline; 39 | } 40 | 41 | .hit-post-username:hover { 42 | text-decoration: underline; 43 | } 44 | 45 | .tt-dataset .tt-suggestion { 46 | cursor: pointer; 47 | } 48 | 49 | .tt-dataset .tt-suggestion:focus,.tt-dataset .tt-suggestion:hover,.tt-dataset .tt-suggestion:active { 50 | background-color: $es-cursor-color; 51 | } 52 | 53 | .tt-dataset .tt-suggestion em { 54 | background-color: $es-highlight-background-color; 55 | font-weight: bold; 56 | font-style: normal; 57 | font-weight: inherit; 58 | } 59 | 60 | .es-empty { 61 | padding: 12px; 62 | color: $es-light-font-color; 63 | } 64 | 65 | .es-dataset-users { 66 | padding: 8px 12px; 67 | .hit-user-left, .hit-user-right { 68 | display: table-cell; 69 | vertical-align: middle; 70 | } 71 | .hit-user-right { 72 | padding-left: 8px; 73 | } 74 | .hit-user-username-holder { 75 | margin-bottom: 3px; 76 | } 77 | .hit-user-username { 78 | font-weight: bold; 79 | } 80 | .hit-user-name { 81 | font-size: $es-small-font; 82 | color: $es-light-font-color; 83 | } 84 | .hit-user-name, .hit-user-username-holder { 85 | min-width: 100px; 86 | overflow: hidden; 87 | } 88 | .hit-user-avatar { 89 | width: 45px; 90 | height: 45px; 91 | vertical-align: middle; 92 | border-radius: 50%; 93 | } 94 | .hit-user-custom-ranking { 95 | color: $es-light-font-color; 96 | font-size: $es-small-font; 97 | } 98 | .hit-user-like-heart { 99 | color: $es-heart-color; 100 | } 101 | } 102 | 103 | .es-dataset-tags { 104 | padding: 8px 12px; 105 | .tt-suggestion { 106 | border-top: 1px solid $es-border-color; 107 | } 108 | .hit-tag:focus, .hit-tag:active{ 109 | background-color: #f8f9fc; 110 | } 111 | .hit-tag-name { 112 | font-weight: bold; 113 | } 114 | .hit-tag-topic_count { 115 | color: $es-light-font-color; 116 | font-size: $es-small-font; 117 | } 118 | } 119 | 120 | .es-dataset-posts { 121 | padding: 12px; 122 | &:last-child { 123 | border-bottom: none; 124 | } 125 | .hit-post { 126 | .hit-post-topic-title { 127 | font-size: $es-large-font; 128 | font-weight: bold; 129 | } 130 | .hit-post-topic-views { 131 | font-size: $es-small-font; 132 | color: $es-light-font-color; 133 | } 134 | .hit-post-category-tags { 135 | margin: 2px 0 4px; 136 | } 137 | .hit-post-tag { 138 | color: $es-light-font-color; 139 | font-size: $es-small-font; 140 | padding: 0 3px; 141 | } 142 | .hit-post-category-name { 143 | color: $es-dark-font-color; 144 | font-size: $es-normal-font; 145 | font-weight: normal; 146 | } 147 | .hit-post-content-holder { 148 | color: $es-light-font-color; 149 | font-size: $es-small-font; 150 | } 151 | .hit-post-content { 152 | word-break: break-word; 153 | font-size: $es-small-font; 154 | } 155 | } 156 | } 157 | .tt-dataset-posts > .es-dataset-posts:nth-last-of-type(2){ 158 | margin-bottom: 20px; 159 | } 160 | .tt-dataset-users-tags { 161 | background-color: #f8f9fc; 162 | min-width: 170px; 163 | } 164 | .tt-dataset-posts .show-more { 165 | padding: 5px; 166 | border-bottom: 1px solid $es-border-color; 167 | width: 50%; 168 | position: absolute; 169 | bottom: 0; 170 | } 171 | .tt-menu{ 172 | right: 0!important; 173 | width: 600px; 174 | } 175 | .tt-highlight{ 176 | background-color: #CCEFFF; 177 | } 178 | .Typeahead-spinner{ 179 | position: absolute; 180 | left: 280px; 181 | top: 10px; 182 | display: none; 183 | } -------------------------------------------------------------------------------- /assets/stylesheets/variables.scss: -------------------------------------------------------------------------------- 1 | $es-blue: rgb(0,174,255); 2 | $titan-white: rgb(248,250,255); 3 | $athens-gray: rgb(238, 240, 247); 4 | $ghost: rgb(196, 200, 216); 5 | 6 | $es-large-font: 15px; 7 | $es-normal-font: 13px; 8 | $es-small-font: 11.5px; 9 | 10 | $es-dark-font-color: #222; 11 | $es-light-font-color: #919191; 12 | $es-heart-color: #fa6c8d; 13 | 14 | $es-dropdown-left-panel-width: 450px; 15 | $es-dropdown-right-panel-width: 225px; 16 | $es-dropdown-width: 675px; 17 | 18 | $es-cursor-color: $athens-gray; 19 | $es-background-color: lighten($titan-white, 1%); 20 | $es-panel-background-color: lighten($athens-gray, 3%); 21 | $es-border-color: lighten($ghost, 10%); 22 | $es-highlight-background-color: lighten($es-blue, 40%); 23 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | elasticsearch_enabled: "Enable discourse-elasticsearch plugin" 4 | elasticsearch_autocomplete_enabled: "auto complete js" 5 | elasticsearch_server_ip: "elasticsearch server ip address" 6 | elasticsearch_server_port: "elasticsearch server port" 7 | elasticsearch_discourse_username: "discourse username" 8 | -------------------------------------------------------------------------------- /config/locales/server.zh_CN.yml: -------------------------------------------------------------------------------- 1 | zh_CN: 2 | site_settings: 3 | elasticsearch_enabled: "启用 discourse-elasticsearch 插件" 4 | elasticsearch_autocomplete_enabled: "启用自动补全搜索结果" 5 | elasticsearch_server_ip: "elasticsearch 服务器 ip 地址" 6 | elasticsearch_server_port: "elasticsearch 服务器端口" 7 | elasticsearch_discourse_username: "discourse 用户名" 8 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | elasticsearch_enabled: 3 | default: true 4 | client: true 5 | elasticsearch_autocomplete_enabled: 6 | default: true 7 | client: true 8 | elasticsearch_server_ip: 9 | default: 'localhost' 10 | client: true 11 | elasticsearch_server_port: 12 | default: '9200' 13 | client: true 14 | elasticsearch_discourse_username: 15 | default: 'system' 16 | client: false 17 | -------------------------------------------------------------------------------- /lib/discourse_elasticsearch/elasticsearch_helper.rb: -------------------------------------------------------------------------------- 1 | require 'elasticsearch' 2 | module DiscourseElasticsearch 3 | class ElasticsearchHelper 4 | 5 | USERS_INDEX = "discourse-users".freeze 6 | POSTS_INDEX = "discourse-posts".freeze 7 | TAGS_INDEX = "discourse-tags".freeze 8 | 9 | # rank fragments with just a few words lower than others 10 | # usually they contain less substance 11 | WORDINESS_THRESHOLD = 5 12 | 13 | # detect salutations to avoid indexing with these common words 14 | SKIP_WORDS = ['thanks'] 15 | 16 | def self.index_user(user_id, discourse_event) 17 | user = User.find_by(id: user_id) 18 | return if user.blank? || !guardian.can_see?(user) 19 | 20 | user_record = to_user_record(user) 21 | add_elasticsearch_users(USERS_INDEX, user_record, user_id) 22 | end 23 | 24 | def self.to_user_record(user) 25 | { 26 | objectID: user.id, 27 | url: "/users/#{user.username}", 28 | name: user.name, 29 | username: user.username, 30 | avatar_template: user.avatar_template, 31 | bio_raw: user.user_profile.bio_raw, 32 | post_count: user.post_count, 33 | badge_count: user.badge_count, 34 | likes_given: user.user_stat.likes_given, 35 | likes_received: user.user_stat.likes_received, 36 | days_visited: user.user_stat.days_visited, 37 | topic_count: user.user_stat.topic_count, 38 | posts_read: user.user_stat.posts_read_count, 39 | time_read: user.user_stat.time_read, 40 | created_at: user.created_at.to_i, 41 | updated_at: user.updated_at.to_i, 42 | last_seen_at: user.last_seen_at 43 | } 44 | end 45 | 46 | def self.index_topic(topic_id, discourse_event) 47 | end 48 | 49 | def self.index_post(post_id, discourse_event) 50 | post = Post.find_by(id: post_id) 51 | if should_index_post?(post) 52 | post_records = to_post_records(post) 53 | add_elasticsearch_posts(POSTS_INDEX, post_records) 54 | end 55 | end 56 | 57 | def self.should_index_post?(post) 58 | return false if post.blank? || post.post_type != Post.types[:regular] || !guardian.can_see?(post) 59 | topic = post.topic 60 | return false if topic.blank? || topic.archetype == Archetype.private_message 61 | return true 62 | end 63 | 64 | def self.to_post_records(post) 65 | 66 | post_records = [] 67 | 68 | doc = Nokogiri::HTML(post.cooked) 69 | parts = doc.text.split(/\n/) 70 | 71 | parts.reject! do |content| 72 | content.strip.empty? 73 | end 74 | 75 | # for debugging, print the skips after the loop 76 | # to see what was excluded from indexing 77 | skips = [] 78 | 79 | parts.each_with_index do |content, index| 80 | 81 | # skip anything without any alpha characters 82 | # commonly formatted code lines with only symbols 83 | unless content =~ /\w/ 84 | skips.push(content) 85 | next 86 | end 87 | 88 | words = content.split(/\s+/) 89 | 90 | # don't index short lines that are probably saluations 91 | words.map! do |word| 92 | word.downcase.gsub(/[^0-9a-z]/i, '') 93 | end 94 | if words.length <= WORDINESS_THRESHOLD && (SKIP_WORDS & words).length > 0 95 | skips.push(content) 96 | next 97 | end 98 | 99 | record = { 100 | objectID: "#{post.id}-#{index}", 101 | url: "/t/#{post.topic.slug}/#{post.topic.id}/#{post.post_number}", 102 | post_id: post.id, 103 | part_number: index, 104 | post_number: post.post_number, 105 | created_at: post.created_at.to_i, 106 | updated_at: post.updated_at.to_i, 107 | reads: post.reads, 108 | like_count: post.like_count, 109 | image_url: post.image_url, 110 | word_count: words.length, 111 | is_wordy: words.length >= WORDINESS_THRESHOLD, 112 | content: content[0..8000] 113 | } 114 | 115 | user = post.user 116 | record[:user] = { 117 | id: user.id, 118 | url: "/users/#{user.username}", 119 | name: user.name, 120 | username: user.username, 121 | avatar_template: user.avatar_template 122 | } 123 | 124 | topic = post.topic 125 | if topic 126 | clean_title = topic.title 127 | record[:topic] = { 128 | id: topic.id, 129 | url: "/t/#{topic.slug}/#{topic.id}", 130 | title: clean_title, 131 | views: topic.views, 132 | slug: topic.slug, 133 | like_count: topic.like_count, 134 | tags: topic.tags.map(&:name) 135 | } 136 | 137 | category = topic.category 138 | if category 139 | record[:category] = { 140 | id: category.id, 141 | url: "/c/#{category.slug}", 142 | name: category.name, 143 | color: category.color, 144 | slug: category.slug 145 | } 146 | end 147 | end 148 | 149 | post_records << record 150 | end 151 | 152 | post_records 153 | 154 | end 155 | 156 | def self.to_tag_record(tag) 157 | { 158 | objectID: tag.id, 159 | url: "/tags/#{tag.name}", 160 | name: tag.name, 161 | topic_count: tag.topic_count 162 | } 163 | end 164 | 165 | def self.index_tags(tag_names, discourse_event) 166 | tag_names.each do |tag_name| 167 | tag = Tag.find_by_name(tag_name) 168 | if tag && should_index_tag?(tag) 169 | add_elasticsearch_users(TAGS_INDEX, to_tag_record(tag), tag.id) 170 | end 171 | end 172 | end 173 | 174 | def self.should_index_tag?(tag) 175 | tag.topic_count > 0 176 | end 177 | 178 | def self.add_elasticsearch_users(index_name, record, user_id) 179 | client = elasticsearch_index 180 | client.index index: index_name, id: user_id, body: record 181 | end 182 | 183 | def self.add_elasticsearch_posts(index_name, posts) 184 | client = elasticsearch_index 185 | posts.each do |post| 186 | client.index index: index_name, body: post 187 | end 188 | end 189 | 190 | def self.add_elasticsearch_tags(index_name, tags) 191 | client = elasticsearch_index 192 | tags.each do |tag| 193 | client.index index: index_name, body: tag 194 | end 195 | end 196 | 197 | def self.elasticsearch_index 198 | server_ip = SiteSetting.elasticsearch_server_ip 199 | server_port = SiteSetting.elasticsearch_server_port 200 | client = Elasticsearch::Client.new url: "#{server_ip}:#{server_port}", log: true 201 | return client 202 | end 203 | 204 | def self.clean_indices(index_name) 205 | client = elasticsearch_index 206 | if client.indices.exists? index: index_name 207 | client.indices.delete index: index_name 208 | else 209 | puts "Indices #{index_name} doesn't exist..." 210 | end 211 | end 212 | 213 | def self.create_mapping 214 | client = elasticsearch_index 215 | client.indices.create index: 'discourse-users', 216 | body: { 217 | mappings: { 218 | properties: { 219 | name: { type: 'text', analyzer: 'ik_max_word', search_analyzer: "ik_smart" }, 220 | url: { type: 'text', analyzer: 'ik_max_word', search_analyzer: "ik_smart" }, 221 | username: { type: 'text', analyzer: 'ik_max_word', search_analyzer: "ik_smart" } 222 | } 223 | } 224 | } 225 | 226 | client.indices.create index: 'discourse-posts', 227 | body: { 228 | mappings: { 229 | properties: { 230 | topic: { 231 | properties: { 232 | title: { type: 'text', analyzer: 'ik_max_word', search_analyzer: "ik_smart" } 233 | } 234 | }, 235 | content: { type: 'text', analyzer: 'ik_max_word', search_analyzer: "ik_smart" } 236 | } 237 | } 238 | } 239 | 240 | client.indices.create index: 'discourse-tags', 241 | body: { 242 | mappings: { 243 | properties: { 244 | name: { type: 'text', analyzer: 'ik_max_word', search_analyzer: "ik_smart" }, 245 | url: { type: 'text', analyzer: 'ik_max_word', search_analyzer: "ik_smart" } 246 | } 247 | } 248 | } 249 | end 250 | 251 | def self.guardian 252 | Guardian.new(User.find_by(username: SiteSetting.elasticsearch_discourse_username)) 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/tasks/discourse_elasticsearch.rake: -------------------------------------------------------------------------------- 1 | desc "configure indices and upload data" 2 | task "elasticsearch:initialize" => :environment do 3 | Rake::Task["elasticsearch:configure"].invoke 4 | Rake::Task["elasticsearch:reindex"].invoke 5 | end 6 | 7 | desc "configure elasticsearch index settings" 8 | task "elasticsearch:configure" => :environment do 9 | elasticsearch_configure_users 10 | elasticsearch_configure_posts 11 | elasticsearch_configure_tags 12 | elasticsearch_configure_map 13 | end 14 | 15 | desc "reindex everything to elasticsearch" 16 | task "elasticsearch:reindex" => :environment do 17 | elasticsearch_reindex_users 18 | elasticsearch_reindex_posts 19 | elasticsearch_reindex_tags 20 | end 21 | 22 | desc "reindex users in elasticsearch" 23 | task "elasticsearch:reindex_users" => :environment do 24 | elasticsearch_reindex_users 25 | end 26 | 27 | desc "reindex posts in elasticsearch" 28 | task "elasticsearch:reindex_posts" => :environment do 29 | elasticsearch_reindex_posts 30 | end 31 | 32 | desc "reindex tags in elasticsearch" 33 | task "elasticsearch:reindex_tags" => :environment do 34 | elasticsearch_reindex_tags 35 | end 36 | 37 | def elasticsearch_configure_users 38 | puts "[Starting] Cleaning users index to Elasticsearch" 39 | DiscourseElasticsearch::ElasticsearchHelper.clean_indices(DiscourseElasticsearch::ElasticsearchHelper::USERS_INDEX) 40 | puts "[Finished] Successfully configured users index in Elasticsearch" 41 | end 42 | 43 | def elasticsearch_configure_posts 44 | puts "[Starting] Cleaning posts index to Elasticsearch" 45 | DiscourseElasticsearch::ElasticsearchHelper.clean_indices(DiscourseElasticsearch::ElasticsearchHelper::POSTS_INDEX) 46 | puts "[Finished] Successfully configured posts index in Elasticsearch" 47 | end 48 | 49 | def elasticsearch_configure_tags 50 | puts "[Starting] Cleaning tags index to Elasticsearch" 51 | DiscourseElasticsearch::ElasticsearchHelper.clean_indices(DiscourseElasticsearch::ElasticsearchHelper::TAGS_INDEX) 52 | puts "[Finished] Successfully configured tags index in Elasticsearch" 53 | end 54 | 55 | def elasticsearch_configure_map 56 | puts "[Starting] Creating mapping to Elasticsearch" 57 | DiscourseElasticsearch::ElasticsearchHelper.create_mapping 58 | end 59 | 60 | def elasticsearch_reindex_users 61 | 62 | puts "[Starting] Pushing users to Elasticsearch" 63 | User.all.each do |user| 64 | #user_records << DiscourseElasticsearch::ElasticsearchHelper.to_user_record(user) 65 | puts user.id 66 | user_record = DiscourseElasticsearch::ElasticsearchHelper.index_user(user.id, '') 67 | puts user_record 68 | end 69 | end 70 | 71 | def elasticsearch_reindex_posts 72 | puts "[Starting] Pushing posts to Elasticsearch" 73 | post_records = [] 74 | Post.all.includes(:user, :topic).each do |post| 75 | if DiscourseElasticsearch::ElasticsearchHelper.should_index_post?(post) 76 | post_records << DiscourseElasticsearch::ElasticsearchHelper.to_post_records(post) 77 | end 78 | end 79 | post_records.flatten! 80 | puts "[Progress] Gathered posts from Discourse" 81 | post_records.each_slice(100) do |slice| 82 | DiscourseElasticsearch::ElasticsearchHelper.add_elasticsearch_posts( 83 | DiscourseElasticsearch::ElasticsearchHelper::POSTS_INDEX, slice.flatten) 84 | puts "[Progress] Pushed #{slice.length} post records to Elasticsearch" 85 | end 86 | puts "[Finished] Successfully pushed #{post_records.length} posts to Elasticsearch" 87 | end 88 | 89 | 90 | def elasticsearch_reindex_tags 91 | puts "[Starting] Pushing tags to Elasticsearch" 92 | tag_records = [] 93 | Tag.all.each do |tag| 94 | if DiscourseElasticsearch::ElasticsearchHelper.should_index_tag?(tag) 95 | tag_records << DiscourseElasticsearch::ElasticsearchHelper.to_tag_record(tag) 96 | end 97 | end 98 | puts "[Progress] Gathered tags from Discourse" 99 | DiscourseElasticsearch::ElasticsearchHelper.add_elasticsearch_tags( 100 | DiscourseElasticsearch::ElasticsearchHelper::TAGS_INDEX, tag_records) 101 | puts "[Finished] Successfully pushed #{tag_records.length} tags to Elasticsearch" 102 | end 103 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # name: discourse-elasticsearch 2 | # about: 3 | # version: 0.2 4 | # authors: imMMX 5 | # url: https://github.com/imMMX 6 | 7 | 8 | gem 'json', '2.2.0' 9 | gem 'httpclient', '2.8.3' 10 | gem 'elasticsearch-transport', '7.2.0' 11 | gem 'elasticsearch-api', '7.2.0' 12 | gem 'elasticsearch', '7.2.0' 13 | 14 | 15 | register_asset 'stylesheets/variables.scss' 16 | register_asset 'stylesheets/elasticsearch-base.scss' 17 | register_asset 'stylesheets/elasticsearch-layout.scss' 18 | register_asset 'lib/typehead.bundle.js' 19 | 20 | enabled_site_setting :elasticsearch_enabled 21 | 22 | PLUGIN_NAME ||= "discourse-elasticsearch".freeze 23 | 24 | after_initialize do 25 | load File.expand_path('../lib/discourse_elasticsearch/elasticsearch_helper.rb', __FILE__) 26 | 27 | # see lib/plugin/instance.rb for the methods available in this context 28 | 29 | 30 | module ::DiscourseElasticsearch 31 | class Engine < ::Rails::Engine 32 | engine_name PLUGIN_NAME 33 | isolate_namespace DiscourseElasticsearch 34 | end 35 | end 36 | 37 | require_dependency File.expand_path('../app/jobs/regular/update_elasticsearch_post.rb', __FILE__) 38 | require_dependency File.expand_path('../app/jobs/regular/update_elasticsearch_user.rb', __FILE__) 39 | require_dependency File.expand_path('../app/jobs/regular/update_elasticsearch_topic.rb', __FILE__) 40 | require_dependency File.expand_path('../app/jobs/regular/update_elasticsearch_tag.rb', __FILE__) 41 | require_dependency 'discourse_event' 42 | 43 | require_dependency "application_controller" 44 | class DiscourseElasticsearch::ActionsController < ::ApplicationController 45 | requires_plugin PLUGIN_NAME 46 | 47 | before_action :ensure_logged_in 48 | 49 | def list 50 | render json: success_json 51 | end 52 | end 53 | 54 | DiscourseElasticsearch::Engine.routes.draw do 55 | get "/list" => "actions#list" 56 | end 57 | 58 | Discourse::Application.routes.append do 59 | mount ::DiscourseElasticsearch::Engine, at: "/discourse-elasticsearch" 60 | end 61 | 62 | [:user_created, :user_updated].each do |discourse_event| 63 | DiscourseEvent.on(discourse_event) do |user| 64 | if SiteSetting.elasticsearch_enabled? 65 | Jobs.enqueue_in(0, 66 | :update_elasticsearch_user, 67 | user_id: user.id, 68 | discourse_event: discourse_event 69 | ) 70 | end 71 | end 72 | end 73 | 74 | [:topic_created, :topic_edited, :topic_destroyed, :topic_recovered].each do |discourse_event| 75 | DiscourseEvent.on(discourse_event) do |topic| 76 | if SiteSetting.elasticsearch_enabled? 77 | Jobs.enqueue_in(0, 78 | :update_elasticsearch_topic, 79 | topic_id: topic.id, 80 | discourse_event: discourse_event 81 | ) 82 | Jobs.enqueue_in(0, 83 | :update_elasticsearch_tag, 84 | tags: topic.tags.map(&:name), 85 | discourse_event: discourse_event 86 | ) 87 | end 88 | end 89 | end 90 | 91 | [:post_created, :post_edited, :post_destroyed, :post_recovered].each do |discourse_event| 92 | DiscourseEvent.on(discourse_event) do |post| 93 | if SiteSetting.elasticsearch_enabled? 94 | Jobs.enqueue_in(0, 95 | :update_elasticsearch_post, 96 | post_id: post.id, 97 | discourse_event: discourse_event 98 | ) 99 | if post.topic 100 | Jobs.enqueue_in(0, 101 | :update_elasticsearch_tag, 102 | tags: post.topic.tags.map(&:name), 103 | discourse_event: discourse_event 104 | ) 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/requests/actions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe discourse-elasticsearch::ActionsController do 4 | before do 5 | Jobs.run_immediately! 6 | end 7 | 8 | it 'can list' do 9 | sign_in(Fabricate(:user)) 10 | get "/discourse-elasticsearch/list.json" 11 | expect(response.status).to eq(200) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/javascripts/acceptance/discourse-elasticsearch-test.js.es6: -------------------------------------------------------------------------------- 1 | import { acceptance } from "helpers/qunit-helpers"; 2 | 3 | acceptance("discourse-elasticsearch", { loggedIn: true }); 4 | 5 | test("discourse-elasticsearch works", async assert => { 6 | await visit("/admin/plugins/discourse-elasticsearch"); 7 | 8 | assert.ok(false, "it shows the discourse-elasticsearch button"); 9 | }); 10 | --------------------------------------------------------------------------------