ni_nitdoc: Use Sorensen-Dice coefficient to sort QuickSearch results
[nit.git] / share / ni_nitdoc / scripts / Nitdoc.QuickSearch.js
1 /*
2 * QuickSearch
3 */
4
5 // Current search results preview table
6 var currentTable = null;
7
8 //Hightlighted index in search result preview table
9 var currentIndex = -1;
10
11 $(document).ready(function() {
12
13 // Insert search field
14 $("nav.main ul")
15 .append(
16 $(document.createElement("li"))
17 .append(
18 $(document.createElement("form"))
19 .append(
20 $(document.createElement("input"))
21 .attr({
22 id: "search",
23 type: "text",
24 autocomplete: "off",
25 value: "quick search..."
26 })
27 .addClass("notUsed")
28
29 // Key management
30 .keyup(function(e) {
31 switch(e.keyCode) {
32 // Up
33 case 38:
34 Nitdoc.QuickSearch.selectPrevResult();
35 break;
36
37 // Down
38 case 40:
39 Nitdoc.QuickSearch.selectNextResult();
40 break;
41 // Enter
42 case 13:
43 Nitdoc.QuickSearch.goToResult();
44 return false;
45 break;
46
47 // Escape
48 case 27:
49 $(this).blur();
50 Nitdoc.QuickSearch.close();
51 break;
52
53 // Other keys
54 default:
55 var query = $("#search").val();
56 if(!query) {
57 return false;
58 }
59 var results = Nitdoc.QuickSearch.search(query);
60 Nitdoc.QuickSearch.showResults(query, results);
61 break;
62 }
63 })
64 .focusout(function() {
65 if($(this).val() == "") {
66 $(this).addClass("notUsed");
67 $(this).val("quick search...");
68 }
69 })
70 .focusin(function() {
71 if($(this).val() == "quick search...") {
72 $(this).removeClass("notUsed");
73 $(this).val("");
74 }
75 })
76 )
77 .submit( function() {
78 return false;
79 })
80 )
81 );
82
83 // Close quicksearch list on click
84 $(document).click(function(e) {
85 Nitdoc.QuickSearch.close();
86 });
87 });
88
89 var Nitdoc = {};
90 Nitdoc.QuickSearch = {};
91
92 // Do search
93 Nitdoc.QuickSearch.search = function(query) {
94 // Escape regexp related characters in query
95 query = query.replace(/\\/gi, "\\\\");
96 query = query.replace(/\[/gi, "\\[");
97 query = query.replace(/\|/gi, "\\|");
98 query = query.replace(/\*/gi, "\\*");
99 query = query.replace(/\+/gi, "\\+");
100 query = query.replace(/\?/gi, "\\?");
101 query = query.replace(/\(/gi, "\\(");
102 query = query.replace(/\)/gi, "\\)");
103 query = query.replace(/&/gi, "&&");
104 // Look for matches in JSON entries
105 var results = new Array();
106 var regexp = new RegExp("^" + query, "i");
107 for(var entry in entries) {
108 for(var i in entries[entry]) {
109 var result = entries[entry][i];
110 result.entry = entry;
111 result.distance = query.dice(entry);
112 results[results.length] = result;
113 }
114 }
115 results.sort(Nitdoc.QuickSearch.resultSorter);
116 return results;
117 }
118
119 // Sort an array of results
120 Nitdoc.QuickSearch.resultSorter = function(a, b){
121 if(a.distance < b.distance) {
122 return 1;
123 } else if(a.distance > b.distance) {
124 return -1;
125 }
126 return 0;
127 }
128
129 // Display resulsts in popup table
130 Nitdoc.QuickSearch.showResults = function(query, results) {
131 // Remove previous table
132 if(currentTable != null) {
133 currentTable.remove();
134 }
135 // Build results table
136 currentIndex = -1;
137 currentTable = $(document.createElement("table"));
138 var overflow = 0;
139 for(var i in results) {
140 if(i > 10) {
141 overflow++;
142 break;
143 }
144 var result = results[i];
145 currentTable.append(
146 $(document.createElement("tr"))
147 .data("searchDetails", {name: result.entry, url: result.url})
148 .data("index", i)
149 .append($(document.createElement("td")).html(result.entry))
150 .append(
151 $(document.createElement("td"))
152 .addClass("entryInfo")
153 .html(result.txt + "&nbsp;&raquo;")
154 )
155 .mouseover( function() {
156 $(currentTable.find("tr")[currentIndex]).removeClass("activeSearchResult");
157 $(this).addClass("activeSearchResult");
158 currentIndex = $(this).data("index");
159 })
160 .mouseout( function() {
161 $(this).removeClass("activeSearchResult");
162 })
163 .click( function() {
164 window.location = $(this).data("searchDetails")["url"];
165 })
166 );
167 }
168 if(overflow > 0) {
169 currentTable.append(
170 $("<tr class='overflow'>")
171 .append(
172 $("<td colspan='2'>")
173 .append(
174 $("<a href='#' title='Show all results' data-query='"+ query +"'>" + overflow + " more results for '" + query + "'</a>")
175 .click(function() {
176 window.location = "search.html#q=" + $(this).attr("data-query");
177 if(window.location.href.indexOf("search.html") > -1) {
178 location.reload();
179 }
180 })
181 )
182 )
183 );
184 }
185 // Initialize table properties
186 currentTable.attr("id", "searchTable");
187 currentTable.css("position", "absolute");
188 currentTable.width($("#search").outerWidth());
189 $("body").append(currentTable);
190 currentTable.offset({left: $("#search").offset().left + ($("#search").outerWidth() - currentTable.outerWidth()), top: $("#search").offset().top + $("#search").outerHeight()});
191 // Preselect first entry
192 if(currentTable.find("tr").length > 0) {
193 currentIndex = 0;
194 $(currentTable.find("tr")[currentIndex]).addClass("activeSearchResult");
195 $("#search").focus();
196 }
197 }
198
199 // Select the previous result
200 Nitdoc.QuickSearch.selectPrevResult = function() {
201 // If already on first result, focus search input
202 if(currentIndex == 0) {
203 $("#search").val($(currentTable.find("tr")[currentIndex]).data("searchDetails").name);
204 $("#search").focus();
205 // Else select previous result
206 } else if(currentIndex > 0) {
207 $(currentTable.find("tr")[currentIndex]).removeClass("activeSearchResult");
208 currentIndex--;
209 $(currentTable.find("tr")[currentIndex]).addClass("activeSearchResult");
210 $("#search").val($(currentTable.find("tr")[currentIndex]).data("searchDetails").name);
211 $("#search").focus();
212 }
213 }
214
215 // Select the next result
216 Nitdoc.QuickSearch.selectNextResult = function() {
217 if(currentIndex < currentTable.find("tr").length - 1) {
218 if($(currentTable.find("tr")[currentIndex + 1]).hasClass("overflow")) {
219 return;
220 }
221 $(currentTable.find("tr")[currentIndex]).removeClass("activeSearchResult");
222 currentIndex++;
223 $(currentTable.find("tr")[currentIndex]).addClass("activeSearchResult");
224 $("#search").val($(currentTable.find("tr")[currentIndex]).data("searchDetails").name);
225 $("#search").focus();
226 }
227 }
228
229 // Load selected search result
230 Nitdoc.QuickSearch.goToResult = function() {
231 if(currentIndex > -1) {
232 window.location = $(currentTable.find("tr")[currentIndex]).data("searchDetails").url;
233 return;
234 }
235
236 if($("#search").val().length == 0) { return; }
237
238 window.location = "search.html#q=" + $("#search").val();
239 if(window.location.href.indexOf("search.html") > -1) {
240 location.reload();
241 }
242 }
243
244 // Close the QuickSearch results
245 Nitdoc.QuickSearch.close = function(target) {
246 if(target != $("#search")[0] && target != $("#searchTable")[0]) {
247 if(currentTable != null) {
248 currentTable.remove();
249 currentTable = null;
250 }
251 }
252 }
253
254 /* Utils */
255
256 // Calculate levenshtein distance beetween two strings
257 // see: http://en.wikipedia.org/wiki/Levenshtein_distance
258 String.prototype.levenshtein = function(other) {
259 var matrix = new Array();
260
261 for(var i = 0; i <= this.length; i++) {
262 matrix[i] = new Array();
263 matrix[i][0] = i;
264 }
265 for(var j = 0; j <= other.length; j++) {
266 matrix[0][j] = j;
267 }
268 var cost = 0;
269 for(var i = 1; i <= this.length; i++) {
270 for(var j = 1; j <= other.length; j++) {
271 if(this.charAt(i - 1) == other.charAt(j - 1)) {
272 cost = 0;
273 } else if(this.charAt(i - 1).toLowerCase() == other.charAt(j - 1).toLowerCase()) {
274 cost = 0.5;
275 } else {
276 cost = 1;
277 }
278 matrix[i][j] = Math.min(
279 matrix[i - 1][j] + 1, // deletion
280 matrix[i][j - 1] + 1, // insertion
281 matrix[i - 1][j - 1] + cost // substitution
282 );
283 }
284 }
285 return matrix[this.length][other.length]
286 }
287
288 // Compare two strings using Sorensen-Dice Coefficient
289 // see: http://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient
290 String.prototype.dice = function(other) {
291 var length1 = this.length - 1;
292 var length2 = other.length - 1;
293 if(length1 < 1 || length2 < 1) return 0;
294
295 var bigrams2 = [];
296 for(var i = 0; i < length2; i++) {
297 bigrams2.push(other.substr(i, 2));
298 }
299
300 var intersection = 0;
301 for(var i = 0; i < length1; i++) {
302 var bigram1 = this.substr(i, 2);
303 for(var j = 0; j < length2; j++) {
304 if(bigram1 == bigrams2[j]) {
305 intersection += 2;
306 bigrams2[j] = null;
307 break;
308 } else if (bigram1 && bigrams2[j] && bigram1.toLowerCase() == bigrams2[j].toLowerCase()) {
309 intersection += 1;
310 bigrams2[j] = null;
311 break;
312 }
313 }
314 }
315 return (2.0 * intersection) / (length1 + length2);
316 }