1/*
2 * searchtools.js
3 * ~~~~~~~~~~~~~~~~
4 *
5 * Sphinx JavaScript utilities for the full-text search.
6 *
7 * :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
8 * :license: BSD, see LICENSE for details.
9 *
10 */
11
12if (!Scorer) {
13  /**
14   * Simple result scoring code.
15   */
16  var Scorer = {
17    // Implement the following function to further tweak the score for each result
18    // The function takes a result array [filename, title, anchor, descr, score]
19    // and returns the new score.
20    /*
21    score: function(result) {
22      return result[4];
23    },
24    */
25
26    // query matches the full name of an object
27    objNameMatch: 11,
28    // or matches in the last dotted part of the object name
29    objPartialMatch: 6,
30    // Additive scores depending on the priority of the object
31    objPrio: {0:  15,   // used to be importantResults
32              1:  5,   // used to be objectResults
33              2: -5},  // used to be unimportantResults
34    //  Used when the priority is not in the mapping.
35    objPrioDefault: 0,
36
37    // query found in title
38    title: 15,
39    partialTitle: 7,
40    // query found in terms
41    term: 5,
42    partialTerm: 2
43  };
44}
45
46if (!splitQuery) {
47  function splitQuery(query) {
48    return query.split(/\s+/);
49  }
50}
51
52/**
53 * Search Module
54 */
55var Search = {
56
57  _index : null,
58  _queued_query : null,
59  _pulse_status : -1,
60
61  htmlToText : function(htmlString) {
62      var virtualDocument = document.implementation.createHTMLDocument('virtual');
63      var htmlElement = $(htmlString, virtualDocument);
64      htmlElement.find('.headerlink').remove();
65      docContent = htmlElement.find('[role=main]')[0];
66      if(docContent === undefined) {
67          console.warn("Content block not found. Sphinx search tries to obtain it " +
68                       "via '[role=main]'. Could you check your theme or template.");
69          return "";
70      }
71      return docContent.textContent || docContent.innerText;
72  },
73
74  init : function() {
75      var params = $.getQueryParameters();
76      if (params.q) {
77          var query = params.q[0];
78          $('input[name="q"]')[0].value = query;
79          this.performSearch(query);
80      }
81  },
82
83  loadIndex : function(url) {
84    $.ajax({type: "GET", url: url, data: null,
85            dataType: "script", cache: true,
86            complete: function(jqxhr, textstatus) {
87              if (textstatus != "success") {
88                document.getElementById("searchindexloader").src = url;
89              }
90            }});
91  },
92
93  setIndex : function(index) {
94    var q;
95    this._index = index;
96    if ((q = this._queued_query) !== null) {
97      this._queued_query = null;
98      Search.query(q);
99    }
100  },
101
102  hasIndex : function() {
103      return this._index !== null;
104  },
105
106  deferQuery : function(query) {
107      this._queued_query = query;
108  },
109
110  stopPulse : function() {
111      this._pulse_status = 0;
112  },
113
114  startPulse : function() {
115    if (this._pulse_status >= 0)
116        return;
117    function pulse() {
118      var i;
119      Search._pulse_status = (Search._pulse_status + 1) % 4;
120      var dotString = '';
121      for (i = 0; i < Search._pulse_status; i++)
122        dotString += '.';
123      Search.dots.text(dotString);
124      if (Search._pulse_status > -1)
125        window.setTimeout(pulse, 500);
126    }
127    pulse();
128  },
129
130  /**
131   * perform a search for something (or wait until index is loaded)
132   */
133  performSearch : function(query) {
134    // create the required interface elements
135    this.out = $('#search-results');
136    this.title = $('<h2>' + _('Searching') + '</h2>').appendTo(this.out);
137    this.dots = $('<span></span>').appendTo(this.title);
138    this.status = $('<p class="search-summary">&nbsp;</p>').appendTo(this.out);
139    this.output = $('<ul class="search"/>').appendTo(this.out);
140
141    $('#search-progress').text(_('Preparing search...'));
142    this.startPulse();
143
144    // index already loaded, the browser was quick!
145    if (this.hasIndex())
146      this.query(query);
147    else
148      this.deferQuery(query);
149  },
150
151  /**
152   * execute search (requires search index to be loaded)
153   */
154  query : function(query) {
155    var i;
156
157    // stem the searchterms and add them to the correct list
158    var stemmer = new Stemmer();
159    var searchterms = [];
160    var excluded = [];
161    var hlterms = [];
162    var tmp = splitQuery(query);
163    var objectterms = [];
164    for (i = 0; i < tmp.length; i++) {
165      if (tmp[i] !== "") {
166          objectterms.push(tmp[i].toLowerCase());
167      }
168
169      if ($u.indexOf(stopwords, tmp[i].toLowerCase()) != -1 || tmp[i] === "") {
170        // skip this "word"
171        continue;
172      }
173      // stem the word
174      var word = stemmer.stemWord(tmp[i].toLowerCase());
175      // prevent stemmer from cutting word smaller than two chars
176      if(word.length < 3 && tmp[i].length >= 3) {
177        word = tmp[i];
178      }
179      var toAppend;
180      // select the correct list
181      if (word[0] == '-') {
182        toAppend = excluded;
183        word = word.substr(1);
184      }
185      else {
186        toAppend = searchterms;
187        hlterms.push(tmp[i].toLowerCase());
188      }
189      // only add if not already in the list
190      if (!$u.contains(toAppend, word))
191        toAppend.push(word);
192    }
193    var highlightstring = '?highlight=' + $.urlencode(hlterms.join(" "));
194
195    // console.debug('SEARCH: searching for:');
196    // console.info('required: ', searchterms);
197    // console.info('excluded: ', excluded);
198
199    // prepare search
200    var terms = this._index.terms;
201    var titleterms = this._index.titleterms;
202
203    // array of [filename, title, anchor, descr, score]
204    var results = [];
205    $('#search-progress').empty();
206
207    // lookup as object
208    for (i = 0; i < objectterms.length; i++) {
209      var others = [].concat(objectterms.slice(0, i),
210                             objectterms.slice(i+1, objectterms.length));
211      results = results.concat(this.performObjectSearch(objectterms[i], others));
212    }
213
214    // lookup as search terms in fulltext
215    results = results.concat(this.performTermsSearch(searchterms, excluded, terms, titleterms));
216
217    // let the scorer override scores with a custom scoring function
218    if (Scorer.score) {
219      for (i = 0; i < results.length; i++)
220        results[i][4] = Scorer.score(results[i]);
221    }
222
223    // now sort the results by score (in opposite order of appearance, since the
224    // display function below uses pop() to retrieve items) and then
225    // alphabetically
226    results.sort(function(a, b) {
227      var left = a[4];
228      var right = b[4];
229      if (left > right) {
230        return 1;
231      } else if (left < right) {
232        return -1;
233      } else {
234        // same score: sort alphabetically
235        left = a[1].toLowerCase();
236        right = b[1].toLowerCase();
237        return (left > right) ? -1 : ((left < right) ? 1 : 0);
238      }
239    });
240
241    // for debugging
242    //Search.lastresults = results.slice();  // a copy
243    //console.info('search results:', Search.lastresults);
244
245    // print the results
246    var resultCount = results.length;
247    function displayNextItem() {
248      // results left, load the summary and display it
249      if (results.length) {
250        var item = results.pop();
251        var listItem = $('<li></li>');
252        var requestUrl = "";
253        var linkUrl = "";
254        if (DOCUMENTATION_OPTIONS.BUILDER === 'dirhtml') {
255          // dirhtml builder
256          var dirname = item[0] + '/';
257          if (dirname.match(/\/index\/$/)) {
258            dirname = dirname.substring(0, dirname.length-6);
259          } else if (dirname == 'index/') {
260            dirname = '';
261          }
262          requestUrl = DOCUMENTATION_OPTIONS.URL_ROOT + dirname;
263          linkUrl = requestUrl;
264
265        } else {
266          // normal html builders
267          requestUrl = DOCUMENTATION_OPTIONS.URL_ROOT + item[0] + DOCUMENTATION_OPTIONS.FILE_SUFFIX;
268          linkUrl = item[0] + DOCUMENTATION_OPTIONS.LINK_SUFFIX;
269        }
270        listItem.append($('<a/>').attr('href',
271            linkUrl +
272            highlightstring + item[2]).html(item[1]));
273        if (item[3]) {
274          listItem.append($('<span> (' + item[3] + ')</span>'));
275          Search.output.append(listItem);
276          setTimeout(function() {
277            displayNextItem();
278          }, 5);
279        } else if (DOCUMENTATION_OPTIONS.HAS_SOURCE) {
280          $.ajax({url: requestUrl,
281                  dataType: "text",
282                  complete: function(jqxhr, textstatus) {
283                    var data = jqxhr.responseText;
284                    if (data !== '' && data !== undefined) {
285                      var summary = Search.makeSearchSummary(data, searchterms, hlterms);
286                      if (summary) {
287                        listItem.append(summary);
288                      }
289                    }
290                    Search.output.append(listItem);
291                    setTimeout(function() {
292                      displayNextItem();
293                    }, 5);
294                  }});
295        } else {
296          // no source available, just display title
297          Search.output.append(listItem);
298          setTimeout(function() {
299            displayNextItem();
300          }, 5);
301        }
302      }
303      // search finished, update title and status message
304      else {
305        Search.stopPulse();
306        Search.title.text(_('Search Results'));
307        if (!resultCount)
308          Search.status.text(_('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.'));
309        else
310            Search.status.text(_('Search finished, found %s page(s) matching the search query.').replace('%s', resultCount));
311        Search.status.fadeIn(500);
312      }
313    }
314    displayNextItem();
315  },
316
317  /**
318   * search for object names
319   */
320  performObjectSearch : function(object, otherterms) {
321    var filenames = this._index.filenames;
322    var docnames = this._index.docnames;
323    var objects = this._index.objects;
324    var objnames = this._index.objnames;
325    var titles = this._index.titles;
326
327    var i;
328    var results = [];
329
330    for (var prefix in objects) {
331      for (var name in objects[prefix]) {
332        var fullname = (prefix ? prefix + '.' : '') + name;
333        var fullnameLower = fullname.toLowerCase()
334        if (fullnameLower.indexOf(object) > -1) {
335          var score = 0;
336          var parts = fullnameLower.split('.');
337          // check for different match types: exact matches of full name or
338          // "last name" (i.e. last dotted part)
339          if (fullnameLower == object || parts[parts.length - 1] == object) {
340            score += Scorer.objNameMatch;
341          // matches in last name
342          } else if (parts[parts.length - 1].indexOf(object) > -1) {
343            score += Scorer.objPartialMatch;
344          }
345          var match = objects[prefix][name];
346          var objname = objnames[match[1]][2];
347          var title = titles[match[0]];
348          // If more than one term searched for, we require other words to be
349          // found in the name/title/description
350          if (otherterms.length > 0) {
351            var haystack = (prefix + ' ' + name + ' ' +
352                            objname + ' ' + title).toLowerCase();
353            var allfound = true;
354            for (i = 0; i < otherterms.length; i++) {
355              if (haystack.indexOf(otherterms[i]) == -1) {
356                allfound = false;
357                break;
358              }
359            }
360            if (!allfound) {
361              continue;
362            }
363          }
364          var descr = objname + _(', in ') + title;
365
366          var anchor = match[3];
367          if (anchor === '')
368            anchor = fullname;
369          else if (anchor == '-')
370            anchor = objnames[match[1]][1] + '-' + fullname;
371          // add custom score for some objects according to scorer
372          if (Scorer.objPrio.hasOwnProperty(match[2])) {
373            score += Scorer.objPrio[match[2]];
374          } else {
375            score += Scorer.objPrioDefault;
376          }
377          results.push([docnames[match[0]], fullname, '#'+anchor, descr, score, filenames[match[0]]]);
378        }
379      }
380    }
381
382    return results;
383  },
384
385  /**
386   * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
387   */
388  escapeRegExp : function(string) {
389    return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
390  },
391
392  /**
393   * search for full-text terms in the index
394   */
395  performTermsSearch : function(searchterms, excluded, terms, titleterms) {
396    var docnames = this._index.docnames;
397    var filenames = this._index.filenames;
398    var titles = this._index.titles;
399
400    var i, j, file;
401    var fileMap = {};
402    var scoreMap = {};
403    var results = [];
404
405    // perform the search on the required terms
406    for (i = 0; i < searchterms.length; i++) {
407      var word = searchterms[i];
408      var files = [];
409      var _o = [
410        {files: terms[word], score: Scorer.term},
411        {files: titleterms[word], score: Scorer.title}
412      ];
413      // add support for partial matches
414      if (word.length > 2) {
415        var word_regex = this.escapeRegExp(word);
416        for (var w in terms) {
417          if (w.match(word_regex) && !terms[word]) {
418            _o.push({files: terms[w], score: Scorer.partialTerm})
419          }
420        }
421        for (var w in titleterms) {
422          if (w.match(word_regex) && !titleterms[word]) {
423              _o.push({files: titleterms[w], score: Scorer.partialTitle})
424          }
425        }
426      }
427
428      // no match but word was a required one
429      if ($u.every(_o, function(o){return o.files === undefined;})) {
430        break;
431      }
432      // found search word in contents
433      $u.each(_o, function(o) {
434        var _files = o.files;
435        if (_files === undefined)
436          return
437
438        if (_files.length === undefined)
439          _files = [_files];
440        files = files.concat(_files);
441
442        // set score for the word in each file to Scorer.term
443        for (j = 0; j < _files.length; j++) {
444          file = _files[j];
445          if (!(file in scoreMap))
446            scoreMap[file] = {};
447          scoreMap[file][word] = o.score;
448        }
449      });
450
451      // create the mapping
452      for (j = 0; j < files.length; j++) {
453        file = files[j];
454        if (file in fileMap && fileMap[file].indexOf(word) === -1)
455          fileMap[file].push(word);
456        else
457          fileMap[file] = [word];
458      }
459    }
460
461    // now check if the files don't contain excluded terms
462    for (file in fileMap) {
463      var valid = true;
464
465      // check if all requirements are matched
466      var filteredTermCount = // as search terms with length < 3 are discarded: ignore
467        searchterms.filter(function(term){return term.length > 2}).length
468      if (
469        fileMap[file].length != searchterms.length &&
470        fileMap[file].length != filteredTermCount
471      ) continue;
472
473      // ensure that none of the excluded terms is in the search result
474      for (i = 0; i < excluded.length; i++) {
475        if (terms[excluded[i]] == file ||
476            titleterms[excluded[i]] == file ||
477            $u.contains(terms[excluded[i]] || [], file) ||
478            $u.contains(titleterms[excluded[i]] || [], file)) {
479          valid = false;
480          break;
481        }
482      }
483
484      // if we have still a valid result we can add it to the result list
485      if (valid) {
486        // select one (max) score for the file.
487        // for better ranking, we should calculate ranking by using words statistics like basic tf-idf...
488        var score = $u.max($u.map(fileMap[file], function(w){return scoreMap[file][w]}));
489        results.push([docnames[file], titles[file], '', null, score, filenames[file]]);
490      }
491    }
492    return results;
493  },
494
495  /**
496   * helper function to return a node containing the
497   * search summary for a given text. keywords is a list
498   * of stemmed words, hlwords is the list of normal, unstemmed
499   * words. the first one is used to find the occurrence, the
500   * latter for highlighting it.
501   */
502  makeSearchSummary : function(htmlText, keywords, hlwords) {
503    var text = Search.htmlToText(htmlText);
504    if (text == "") {
505      return null;
506    }
507    var textLower = text.toLowerCase();
508    var start = 0;
509    $.each(keywords, function() {
510      var i = textLower.indexOf(this.toLowerCase());
511      if (i > -1)
512        start = i;
513    });
514    start = Math.max(start - 120, 0);
515    var excerpt = ((start > 0) ? '...' : '') +
516      $.trim(text.substr(start, 240)) +
517      ((start + 240 - text.length) ? '...' : '');
518    var rv = $('<p class="context"></p>').text(excerpt);
519    $.each(hlwords, function() {
520      rv = rv.highlightText(this, 'highlighted');
521    });
522    return rv;
523  }
524};
525
526$(document).ready(function() {
527  Search.init();
528});
529