searchtools.js - sphere - GPU-based 3D discrete element method algorithm with optional fluid coupling
HTML git clone git://src.adamsgaard.dk/sphere
DIR Log
DIR Files
DIR Refs
DIR LICENSE
---
searchtools.js (22464B)
---
1 /*
2 * Sphinx JavaScript utilities for the full-text search.
3 */
4 "use strict";
5
6 /**
7 * Simple result scoring code.
8 */
9 if (typeof Scorer === "undefined") {
10 var Scorer = {
11 // Implement the following function to further tweak the score for each result
12 // The function takes a result array [docname, title, anchor, descr, score, filename]
13 // and returns the new score.
14 /*
15 score: result => {
16 const [docname, title, anchor, descr, score, filename, kind] = result
17 return score
18 },
19 */
20
21 // query matches the full name of an object
22 objNameMatch: 11,
23 // or matches in the last dotted part of the object name
24 objPartialMatch: 6,
25 // Additive scores depending on the priority of the object
26 objPrio: {
27 0: 15, // used to be importantResults
28 1: 5, // used to be objectResults
29 2: -5, // used to be unimportantResults
30 },
31 // Used when the priority is not in the mapping.
32 objPrioDefault: 0,
33
34 // query found in title
35 title: 15,
36 partialTitle: 7,
37 // query found in terms
38 term: 5,
39 partialTerm: 2,
40 };
41 }
42
43 // Global search result kind enum, used by themes to style search results.
44 // prettier-ignore
45 class SearchResultKind {
46 static get index() { return "index"; }
47 static get object() { return "object"; }
48 static get text() { return "text"; }
49 static get title() { return "title"; }
50 }
51
52 const _removeChildren = (element) => {
53 while (element && element.lastChild) element.removeChild(element.lastChild);
54 };
55
56 /**
57 * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
58 */
59 const _escapeRegExp = (string) =>
60 string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
61
62 const _escapeHTML = (text) => {
63 return text
64 .replaceAll("&", "&")
65 .replaceAll("<", "<")
66 .replaceAll(">", ">")
67 .replaceAll('"', """)
68 .replaceAll("'", "'");
69 };
70
71 const _displayItem = (item, searchTerms, highlightTerms) => {
72 const docBuilder = DOCUMENTATION_OPTIONS.BUILDER;
73 const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX;
74 const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX;
75 const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY;
76 const contentRoot = document.documentElement.dataset.content_root;
77
78 const [docName, title, anchor, descr, score, _filename, kind] = item;
79
80 let listItem = document.createElement("li");
81 // Add a class representing the item's type:
82 // can be used by a theme's CSS selector for styling
83 // See SearchResultKind for the class names.
84 listItem.classList.add(`kind-${kind}`);
85 let requestUrl;
86 let linkUrl;
87 if (docBuilder === "dirhtml") {
88 // dirhtml builder
89 let dirname = docName + "/";
90 if (dirname.match(/\/index\/$/))
91 dirname = dirname.substring(0, dirname.length - 6);
92 else if (dirname === "index/") dirname = "";
93 requestUrl = contentRoot + dirname;
94 linkUrl = requestUrl;
95 } else {
96 // normal html builders
97 requestUrl = contentRoot + docName + docFileSuffix;
98 linkUrl = docName + docLinkSuffix;
99 }
100 let linkEl = listItem.appendChild(document.createElement("a"));
101 linkEl.href = linkUrl + anchor;
102 linkEl.dataset.score = score;
103 linkEl.innerHTML = _escapeHTML(title);
104 if (descr) {
105 listItem.appendChild(document.createElement("span")).innerHTML =
106 ` (${_escapeHTML(descr)})`;
107 // highlight search terms in the description
108 if (SPHINX_HIGHLIGHT_ENABLED)
109 // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js
110 highlightTerms.forEach((term) =>
111 _highlightText(listItem, term, "highlighted"),
112 );
113 } else if (showSearchSummary)
114 fetch(requestUrl)
115 .then((responseData) => responseData.text())
116 .then((data) => {
117 if (data)
118 listItem.appendChild(
119 Search.makeSearchSummary(data, searchTerms, anchor),
120 );
121 // highlight search terms in the summary
122 if (SPHINX_HIGHLIGHT_ENABLED)
123 // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js
124 highlightTerms.forEach((term) =>
125 _highlightText(listItem, term, "highlighted"),
126 );
127 });
128 Search.output.appendChild(listItem);
129 };
130 const _finishSearch = (resultCount) => {
131 Search.stopPulse();
132 Search.title.innerText = _("Search Results");
133 if (!resultCount)
134 Search.status.innerText = Documentation.gettext(
135 "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories.",
136 );
137 else
138 Search.status.innerText = Documentation.ngettext(
139 "Search finished, found one page matching the search query.",
140 "Search finished, found ${resultCount} pages matching the search query.",
141 resultCount,
142 ).replace("${resultCount}", resultCount);
143 };
144 const _displayNextItem = (
145 results,
146 resultCount,
147 searchTerms,
148 highlightTerms,
149 ) => {
150 // results left, load the summary and display it
151 // this is intended to be dynamic (don't sub resultsCount)
152 if (results.length) {
153 _displayItem(results.pop(), searchTerms, highlightTerms);
154 setTimeout(
155 () => _displayNextItem(results, resultCount, searchTerms, highlightTerms),
156 5,
157 );
158 }
159 // search finished, update title and status message
160 else _finishSearch(resultCount);
161 };
162 // Helper function used by query() to order search results.
163 // Each input is an array of [docname, title, anchor, descr, score, filename, kind].
164 // Order the results by score (in opposite order of appearance, since the
165 // `_displayNextItem` function uses pop() to retrieve items) and then alphabetically.
166 const _orderResultsByScoreThenName = (a, b) => {
167 const leftScore = a[4];
168 const rightScore = b[4];
169 if (leftScore === rightScore) {
170 // same score: sort alphabetically
171 const leftTitle = a[1].toLowerCase();
172 const rightTitle = b[1].toLowerCase();
173 if (leftTitle === rightTitle) return 0;
174 return leftTitle > rightTitle ? -1 : 1; // inverted is intentional
175 }
176 return leftScore > rightScore ? 1 : -1;
177 };
178
179 /**
180 * Default splitQuery function. Can be overridden in ``sphinx.search`` with a
181 * custom function per language.
182 *
183 * The regular expression works by splitting the string on consecutive characters
184 * that are not Unicode letters, numbers, underscores, or emoji characters.
185 * This is the same as ``\W+`` in Python, preserving the surrogate pair area.
186 */
187 if (typeof splitQuery === "undefined") {
188 var splitQuery = (query) =>
189 query
190 .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu)
191 .filter((term) => term); // remove remaining empty strings
192 }
193
194 /**
195 * Search Module
196 */
197 const Search = {
198 _index: null,
199 _queued_query: null,
200 _pulse_status: -1,
201
202 htmlToText: (htmlString, anchor) => {
203 const htmlElement = new DOMParser().parseFromString(
204 htmlString,
205 "text/html",
206 );
207 for (const removalQuery of [".headerlink", "script", "style"]) {
208 htmlElement.querySelectorAll(removalQuery).forEach((el) => {
209 el.remove();
210 });
211 }
212 if (anchor) {
213 const anchorContent = htmlElement.querySelector(
214 `[role="main"] ${anchor}`,
215 );
216 if (anchorContent) return anchorContent.textContent;
217
218 console.warn(
219 `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.`,
220 );
221 }
222
223 // if anchor not specified or not found, fall back to main content
224 const docContent = htmlElement.querySelector('[role="main"]');
225 if (docContent) return docContent.textContent;
226
227 console.warn(
228 "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template.",
229 );
230 return "";
231 },
232
233 init: () => {
234 const query = new URLSearchParams(window.location.search).get("q");
235 document
236 .querySelectorAll('input[name="q"]')
237 .forEach((el) => (el.value = query));
238 if (query) Search.performSearch(query);
239 },
240
241 loadIndex: (url) =>
242 (document.body.appendChild(document.createElement("script")).src = url),
243
244 setIndex: (index) => {
245 Search._index = index;
246 if (Search._queued_query !== null) {
247 const query = Search._queued_query;
248 Search._queued_query = null;
249 Search.query(query);
250 }
251 },
252
253 hasIndex: () => Search._index !== null,
254
255 deferQuery: (query) => (Search._queued_query = query),
256
257 stopPulse: () => (Search._pulse_status = -1),
258
259 startPulse: () => {
260 if (Search._pulse_status >= 0) return;
261
262 const pulse = () => {
263 Search._pulse_status = (Search._pulse_status + 1) % 4;
264 Search.dots.innerText = ".".repeat(Search._pulse_status);
265 if (Search._pulse_status >= 0) window.setTimeout(pulse, 500);
266 };
267 pulse();
268 },
269
270 /**
271 * perform a search for something (or wait until index is loaded)
272 */
273 performSearch: (query) => {
274 // create the required interface elements
275 const searchText = document.createElement("h2");
276 searchText.textContent = _("Searching");
277 const searchSummary = document.createElement("p");
278 searchSummary.classList.add("search-summary");
279 searchSummary.innerText = "";
280 const searchList = document.createElement("ul");
281 searchList.setAttribute("role", "list");
282 searchList.classList.add("search");
283
284 const out = document.getElementById("search-results");
285 Search.title = out.appendChild(searchText);
286 Search.dots = Search.title.appendChild(document.createElement("span"));
287 Search.status = out.appendChild(searchSummary);
288 Search.output = out.appendChild(searchList);
289
290 const searchProgress = document.getElementById("search-progress");
291 // Some themes don't use the search progress node
292 if (searchProgress) {
293 searchProgress.innerText = _("Preparing search...");
294 }
295 Search.startPulse();
296
297 // index already loaded, the browser was quick!
298 if (Search.hasIndex()) Search.query(query);
299 else Search.deferQuery(query);
300 },
301
302 _parseQuery: (query) => {
303 // stem the search terms and add them to the correct list
304 const stemmer = new Stemmer();
305 const searchTerms = new Set();
306 const excludedTerms = new Set();
307 const highlightTerms = new Set();
308 const objectTerms = new Set(splitQuery(query.toLowerCase().trim()));
309 splitQuery(query.trim()).forEach((queryTerm) => {
310 const queryTermLower = queryTerm.toLowerCase();
311
312 // maybe skip this "word"
313 // stopwords set is from language_data.js
314 if (stopwords.has(queryTermLower) || queryTerm.match(/^\d+$/)) return;
315
316 // stem the word
317 let word = stemmer.stemWord(queryTermLower);
318 // select the correct list
319 if (word[0] === "-") excludedTerms.add(word.substr(1));
320 else {
321 searchTerms.add(word);
322 highlightTerms.add(queryTermLower);
323 }
324 });
325
326 if (SPHINX_HIGHLIGHT_ENABLED) {
327 // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js
328 localStorage.setItem(
329 "sphinx_highlight_terms",
330 [...highlightTerms].join(" "),
331 );
332 }
333
334 // console.debug("SEARCH: searching for:");
335 // console.info("required: ", [...searchTerms]);
336 // console.info("excluded: ", [...excludedTerms]);
337
338 return [query, searchTerms, excludedTerms, highlightTerms, objectTerms];
339 },
340
341 /**
342 * execute search (requires search index to be loaded)
343 */
344 _performSearch: (
345 query,
346 searchTerms,
347 excludedTerms,
348 highlightTerms,
349 objectTerms,
350 ) => {
351 const filenames = Search._index.filenames;
352 const docNames = Search._index.docnames;
353 const titles = Search._index.titles;
354 const allTitles = Search._index.alltitles;
355 const indexEntries = Search._index.indexentries;
356
357 // Collect multiple result groups to be sorted separately and then ordered.
358 // Each is an array of [docname, title, anchor, descr, score, filename, kind].
359 const normalResults = [];
360 const nonMainIndexResults = [];
361
362 _removeChildren(document.getElementById("search-progress"));
363
364 const queryLower = query.toLowerCase().trim();
365 for (const [title, foundTitles] of Object.entries(allTitles)) {
366 if (
367 title.toLowerCase().trim().includes(queryLower)
368 && queryLower.length >= title.length / 2
369 ) {
370 for (const [file, id] of foundTitles) {
371 const score = Math.round(
372 (Scorer.title * queryLower.length) / title.length,
373 );
374 const boost = titles[file] === title ? 1 : 0; // add a boost for document titles
375 normalResults.push([
376 docNames[file],
377 titles[file] !== title ? `${titles[file]} > ${title}` : title,
378 id !== null ? "#" + id : "",
379 null,
380 score + boost,
381 filenames[file],
382 SearchResultKind.title,
383 ]);
384 }
385 }
386 }
387
388 // search for explicit entries in index directives
389 for (const [entry, foundEntries] of Object.entries(indexEntries)) {
390 if (entry.includes(queryLower) && queryLower.length >= entry.length / 2) {
391 for (const [file, id, isMain] of foundEntries) {
392 const score = Math.round((100 * queryLower.length) / entry.length);
393 const result = [
394 docNames[file],
395 titles[file],
396 id ? "#" + id : "",
397 null,
398 score,
399 filenames[file],
400 SearchResultKind.index,
401 ];
402 if (isMain) {
403 normalResults.push(result);
404 } else {
405 nonMainIndexResults.push(result);
406 }
407 }
408 }
409 }
410
411 // lookup as object
412 objectTerms.forEach((term) =>
413 normalResults.push(...Search.performObjectSearch(term, objectTerms)),
414 );
415
416 // lookup as search terms in fulltext
417 normalResults.push(
418 ...Search.performTermsSearch(searchTerms, excludedTerms),
419 );
420
421 // let the scorer override scores with a custom scoring function
422 if (Scorer.score) {
423 normalResults.forEach((item) => (item[4] = Scorer.score(item)));
424 nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item)));
425 }
426
427 // Sort each group of results by score and then alphabetically by name.
428 normalResults.sort(_orderResultsByScoreThenName);
429 nonMainIndexResults.sort(_orderResultsByScoreThenName);
430
431 // Combine the result groups in (reverse) order.
432 // Non-main index entries are typically arbitrary cross-references,
433 // so display them after other results.
434 let results = [...nonMainIndexResults, ...normalResults];
435
436 // remove duplicate search results
437 // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept
438 let seen = new Set();
439 results = results.reverse().reduce((acc, result) => {
440 let resultStr = result
441 .slice(0, 4)
442 .concat([result[5]])
443 .map((v) => String(v))
444 .join(",");
445 if (!seen.has(resultStr)) {
446 acc.push(result);
447 seen.add(resultStr);
448 }
449 return acc;
450 }, []);
451
452 return results.reverse();
453 },
454
455 query: (query) => {
456 const [
457 searchQuery,
458 searchTerms,
459 excludedTerms,
460 highlightTerms,
461 objectTerms,
462 ] = Search._parseQuery(query);
463 const results = Search._performSearch(
464 searchQuery,
465 searchTerms,
466 excludedTerms,
467 highlightTerms,
468 objectTerms,
469 );
470
471 // for debugging
472 //Search.lastresults = results.slice(); // a copy
473 // console.info("search results:", Search.lastresults);
474
475 // print the results
476 _displayNextItem(results, results.length, searchTerms, highlightTerms);
477 },
478
479 /**
480 * search for object names
481 */
482 performObjectSearch: (object, objectTerms) => {
483 const filenames = Search._index.filenames;
484 const docNames = Search._index.docnames;
485 const objects = Search._index.objects;
486 const objNames = Search._index.objnames;
487 const titles = Search._index.titles;
488
489 const results = [];
490
491 const objectSearchCallback = (prefix, match) => {
492 const name = match[4];
493 const fullname = (prefix ? prefix + "." : "") + name;
494 const fullnameLower = fullname.toLowerCase();
495 if (fullnameLower.indexOf(object) < 0) return;
496
497 let score = 0;
498 const parts = fullnameLower.split(".");
499
500 // check for different match types: exact matches of full name or
501 // "last name" (i.e. last dotted part)
502 if (fullnameLower === object || parts.slice(-1)[0] === object)
503 score += Scorer.objNameMatch;
504 else if (parts.slice(-1)[0].indexOf(object) > -1)
505 score += Scorer.objPartialMatch; // matches in last name
506
507 const objName = objNames[match[1]][2];
508 const title = titles[match[0]];
509
510 // If more than one term searched for, we require other words to be
511 // found in the name/title/description
512 const otherTerms = new Set(objectTerms);
513 otherTerms.delete(object);
514 if (otherTerms.size > 0) {
515 const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase();
516 if (
517 [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0)
518 )
519 return;
520 }
521
522 let anchor = match[3];
523 if (anchor === "") anchor = fullname;
524 else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname;
525
526 const descr = objName + _(", in ") + title;
527
528 // add custom score for some objects according to scorer
529 if (Scorer.objPrio.hasOwnProperty(match[2]))
530 score += Scorer.objPrio[match[2]];
531 else score += Scorer.objPrioDefault;
532
533 results.push([
534 docNames[match[0]],
535 fullname,
536 "#" + anchor,
537 descr,
538 score,
539 filenames[match[0]],
540 SearchResultKind.object,
541 ]);
542 };
543 Object.keys(objects).forEach((prefix) =>
544 objects[prefix].forEach((array) => objectSearchCallback(prefix, array)),
545 );
546 return results;
547 },
548
549 /**
550 * search for full-text terms in the index
551 */
552 performTermsSearch: (searchTerms, excludedTerms) => {
553 // prepare search
554 const terms = Search._index.terms;
555 const titleTerms = Search._index.titleterms;
556 const filenames = Search._index.filenames;
557 const docNames = Search._index.docnames;
558 const titles = Search._index.titles;
559
560 const scoreMap = new Map();
561 const fileMap = new Map();
562
563 // perform the search on the required terms
564 searchTerms.forEach((word) => {
565 const files = [];
566 // find documents, if any, containing the query word in their text/title term indices
567 // use Object.hasOwnProperty to avoid mismatching against prototype properties
568 const arr = [
569 {
570 files: terms.hasOwnProperty(word) ? terms[word] : undefined,
571 score: Scorer.term,
572 },
573 {
574 files: titleTerms.hasOwnProperty(word) ? titleTerms[word] : undefined,
575 score: Scorer.title,
576 },
577 ];
578 // add support for partial matches
579 if (word.length > 2) {
580 const escapedWord = _escapeRegExp(word);
581 if (!terms.hasOwnProperty(word)) {
582 Object.keys(terms).forEach((term) => {
583 if (term.match(escapedWord))
584 arr.push({ files: terms[term], score: Scorer.partialTerm });
585 });
586 }
587 if (!titleTerms.hasOwnProperty(word)) {
588 Object.keys(titleTerms).forEach((term) => {
589 if (term.match(escapedWord))
590 arr.push({ files: titleTerms[term], score: Scorer.partialTitle });
591 });
592 }
593 }
594
595 // no match but word was a required one
596 if (arr.every((record) => record.files === undefined)) return;
597
598 // found search word in contents
599 arr.forEach((record) => {
600 if (record.files === undefined) return;
601
602 let recordFiles = record.files;
603 if (recordFiles.length === undefined) recordFiles = [recordFiles];
604 files.push(...recordFiles);
605
606 // set score for the word in each file
607 recordFiles.forEach((file) => {
608 if (!scoreMap.has(file)) scoreMap.set(file, new Map());
609 const fileScores = scoreMap.get(file);
610 fileScores.set(word, record.score);
611 });
612 });
613
614 // create the mapping
615 files.forEach((file) => {
616 if (!fileMap.has(file)) fileMap.set(file, [word]);
617 else if (fileMap.get(file).indexOf(word) === -1)
618 fileMap.get(file).push(word);
619 });
620 });
621
622 // now check if the files don't contain excluded terms
623 const results = [];
624 for (const [file, wordList] of fileMap) {
625 // check if all requirements are matched
626
627 // as search terms with length < 3 are discarded
628 const filteredTermCount = [...searchTerms].filter(
629 (term) => term.length > 2,
630 ).length;
631 if (
632 wordList.length !== searchTerms.size
633 && wordList.length !== filteredTermCount
634 )
635 continue;
636
637 // ensure that none of the excluded terms is in the search result
638 if (
639 [...excludedTerms].some(
640 (term) =>
641 terms[term] === file
642 || titleTerms[term] === file
643 || (terms[term] || []).includes(file)
644 || (titleTerms[term] || []).includes(file),
645 )
646 )
647 break;
648
649 // select one (max) score for the file.
650 const score = Math.max(...wordList.map((w) => scoreMap.get(file).get(w)));
651 // add result to the result list
652 results.push([
653 docNames[file],
654 titles[file],
655 "",
656 null,
657 score,
658 filenames[file],
659 SearchResultKind.text,
660 ]);
661 }
662 return results;
663 },
664
665 /**
666 * helper function to return a node containing the
667 * search summary for a given text. keywords is a list
668 * of stemmed words.
669 */
670 makeSearchSummary: (htmlText, keywords, anchor) => {
671 const text = Search.htmlToText(htmlText, anchor);
672 if (text === "") return null;
673
674 const textLower = text.toLowerCase();
675 const actualStartPosition = [...keywords]
676 .map((k) => textLower.indexOf(k.toLowerCase()))
677 .filter((i) => i > -1)
678 .slice(-1)[0];
679 const startWithContext = Math.max(actualStartPosition - 120, 0);
680
681 const top = startWithContext === 0 ? "" : "...";
682 const tail = startWithContext + 240 < text.length ? "..." : "";
683
684 let summary = document.createElement("p");
685 summary.classList.add("context");
686 summary.textContent =
687 top + text.substr(startWithContext, 240).trim() + tail;
688
689 return summary;
690 },
691 };
692
693 _ready(Search.init);