codekingpro/portable-devtools
114k
1/*2 * searchtools.js3 * ~~~~~~~~~~~~~~~~4 *5 * Sphinx JavaScript utilities for the full-text search.6 *7 * :copyright: Copyright 2007-2024 by the Sphinx team, see AUTHORS.8 * :license: BSD, see LICENSE for details.9 *10 */11"use strict";12 13/**14 * Simple result scoring code.15 */16if (typeof Scorer === "undefined") {17 var Scorer = {18 // Implement the following function to further tweak the score for each result19 // The function takes a result array [docname, title, anchor, descr, score, filename]20 // and returns the new score.21 /*22 score: result => {23 const [docname, title, anchor, descr, score, filename] = result24 return score25 },26 */27 28 // query matches the full name of an object29 objNameMatch: 11,30 // or matches in the last dotted part of the object name31 objPartialMatch: 6,32 // Additive scores depending on the priority of the object33 objPrio: {34 0: 15, // used to be importantResults35 1: 5, // used to be objectResults36 2: -5, // used to be unimportantResults37 },38 // Used when the priority is not in the mapping.39 objPrioDefault: 0,40 41 // query found in title42 title: 15,43 partialTitle: 7,44 // query found in terms45 term: 5,46 partialTerm: 2,47 };48}49 50const _removeChildren = (element) => {51 while (element && element.lastChild) element.removeChild(element.lastChild);52};53 54/**55 * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping56 */57const _escapeRegExp = (string) =>58 string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string59 60const _displayItem = (item, searchTerms, highlightTerms) => {61 const docBuilder = DOCUMENTATION_OPTIONS.BUILDER;62 const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX;63 const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX;64 const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY;65 const contentRoot = document.documentElement.dataset.content_root;66 67 const [docName, title, anchor, descr, score, _filename] = item;68 69 let listItem = document.createElement("li");70 let requestUrl;71 let linkUrl;72 if (docBuilder === "dirhtml") {73 // dirhtml builder74 let dirname = docName + "/";75 if (dirname.match(/\/index\/$/))76 dirname = dirname.substring(0, dirname.length - 6);77 else if (dirname === "index/") dirname = "";78 requestUrl = contentRoot + dirname;79 linkUrl = requestUrl;80 } else {81 // normal html builders82 requestUrl = contentRoot + docName + docFileSuffix;83 linkUrl = docName + docLinkSuffix;84 }85 let linkEl = listItem.appendChild(document.createElement("a"));86 linkEl.href = linkUrl + anchor;87 linkEl.dataset.score = score;88 linkEl.innerHTML = title;89 if (descr) {90 listItem.appendChild(document.createElement("span")).innerHTML =91 " (" + descr + ")";92 // highlight search terms in the description93 if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js94 highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted"));95 }96 else if (showSearchSummary)97 fetch(requestUrl)98 .then((responseData) => responseData.text())99 .then((data) => {100 if (data)101 listItem.appendChild(102 Search.makeSearchSummary(data, searchTerms, anchor)103 );104 // highlight search terms in the summary105 if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js106 highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted"));107 });108 Search.output.appendChild(listItem);109};110const _finishSearch = (resultCount) => {111 Search.stopPulse();112 Search.title.innerText = _("Search Results");113 if (!resultCount)114 Search.status.innerText = Documentation.gettext(115 "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories."116 );117 else118 Search.status.innerText = _(119 "Search finished, found ${resultCount} page(s) matching the search query."120 ).replace('${resultCount}', resultCount);121};122const _displayNextItem = (123 results,124 resultCount,125 searchTerms,126 highlightTerms,127) => {128 // results left, load the summary and display it129 // this is intended to be dynamic (don't sub resultsCount)130 if (results.length) {131 _displayItem(results.pop(), searchTerms, highlightTerms);132 setTimeout(133 () => _displayNextItem(results, resultCount, searchTerms, highlightTerms),134 5135 );136 }137 // search finished, update title and status message138 else _finishSearch(resultCount);139};140// Helper function used by query() to order search results.141// Each input is an array of [docname, title, anchor, descr, score, filename].142// Order the results by score (in opposite order of appearance, since the143// `_displayNextItem` function uses pop() to retrieve items) and then alphabetically.144const _orderResultsByScoreThenName = (a, b) => {145 const leftScore = a[4];146 const rightScore = b[4];147 if (leftScore === rightScore) {148 // same score: sort alphabetically149 const leftTitle = a[1].toLowerCase();150 const rightTitle = b[1].toLowerCase();151 if (leftTitle === rightTitle) return 0;152 return leftTitle > rightTitle ? -1 : 1; // inverted is intentional153 }154 return leftScore > rightScore ? 1 : -1;155};156 157/**158 * Default splitQuery function. Can be overridden in ``sphinx.search`` with a159 * custom function per language.160 *161 * The regular expression works by splitting the string on consecutive characters162 * that are not Unicode letters, numbers, underscores, or emoji characters.163 * This is the same as ``\W+`` in Python, preserving the surrogate pair area.164 */165if (typeof splitQuery === "undefined") {166 var splitQuery = (query) => query167 .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu)168 .filter(term => term) // remove remaining empty strings169}170 171/**172 * Search Module173 */174const Search = {175 _index: null,176 _queued_query: null,177 _pulse_status: -1,178 179 htmlToText: (htmlString, anchor) => {180 const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html');181 for (const removalQuery of [".headerlinks", "script", "style"]) {182 htmlElement.querySelectorAll(removalQuery).forEach((el) => { el.remove() });183 }184 if (anchor) {185 const anchorContent = htmlElement.querySelector(`[role="main"] ${anchor}`);186 if (anchorContent) return anchorContent.textContent;187 188 console.warn(189 `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.`190 );191 }192 193 // if anchor not specified or not found, fall back to main content194 const docContent = htmlElement.querySelector('[role="main"]');195 if (docContent) return docContent.textContent;196 197 console.warn(198 "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template."199 );200 return "";201 },202 203 init: () => {204 const query = new URLSearchParams(window.location.search).get("q");205 document206 .querySelectorAll('input[name="q"]')207 .forEach((el) => (el.value = query));208 if (query) Search.performSearch(query);209 },210 211 loadIndex: (url) =>212 (document.body.appendChild(document.createElement("script")).src = url),213 214 setIndex: (index) => {215 Search._index = index;216 if (Search._queued_query !== null) {217 const query = Search._queued_query;218 Search._queued_query = null;219 Search.query(query);220 }221 },222 223 hasIndex: () => Search._index !== null,224 225 deferQuery: (query) => (Search._queued_query = query),226 227 stopPulse: () => (Search._pulse_status = -1),228 229 startPulse: () => {230 if (Search._pulse_status >= 0) return;231 232 const pulse = () => {233 Search._pulse_status = (Search._pulse_status + 1) % 4;234 Search.dots.innerText = ".".repeat(Search._pulse_status);235 if (Search._pulse_status >= 0) window.setTimeout(pulse, 500);236 };237 pulse();238 },239 240 /**241 * perform a search for something (or wait until index is loaded)242 */243 performSearch: (query) => {244 // create the required interface elements245 const searchText = document.createElement("h2");246 searchText.textContent = _("Searching");247 const searchSummary = document.createElement("p");248 searchSummary.classList.add("search-summary");249 searchSummary.innerText = "";250 const searchList = document.createElement("ul");251 searchList.classList.add("search");252 253 const out = document.getElementById("search-results");254 Search.title = out.appendChild(searchText);255 Search.dots = Search.title.appendChild(document.createElement("span"));256 Search.status = out.appendChild(searchSummary);257 Search.output = out.appendChild(searchList);258 259 const searchProgress = document.getElementById("search-progress");260 // Some themes don't use the search progress node261 if (searchProgress) {262 searchProgress.innerText = _("Preparing search...");263 }264 Search.startPulse();265 266 // index already loaded, the browser was quick!267 if (Search.hasIndex()) Search.query(query);268 else Search.deferQuery(query);269 },270 271 _parseQuery: (query) => {272 // stem the search terms and add them to the correct list273 const stemmer = new Stemmer();274 const searchTerms = new Set();275 const excludedTerms = new Set();276 const highlightTerms = new Set();277 const objectTerms = new Set(splitQuery(query.toLowerCase().trim()));278 splitQuery(query.trim()).forEach((queryTerm) => {279 const queryTermLower = queryTerm.toLowerCase();280 281 // maybe skip this "word"282 // stopwords array is from language_data.js283 if (284 stopwords.indexOf(queryTermLower) !== -1 ||285 queryTerm.match(/^\d+$/)286 )287 return;288 289 // stem the word290 let word = stemmer.stemWord(queryTermLower);291 // select the correct list292 if (word[0] === "-") excludedTerms.add(word.substr(1));293 else {294 searchTerms.add(word);295 highlightTerms.add(queryTermLower);296 }297 });298 299 if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js300 localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" "))301 }302 303 // console.debug("SEARCH: searching for:");304 // console.info("required: ", [...searchTerms]);305 // console.info("excluded: ", [...excludedTerms]);306 307 return [query, searchTerms, excludedTerms, highlightTerms, objectTerms];308 },309 310 /**311 * execute search (requires search index to be loaded)312 */313 _performSearch: (query, searchTerms, excludedTerms, highlightTerms, objectTerms) => {314 const filenames = Search._index.filenames;315 const docNames = Search._index.docnames;316 const titles = Search._index.titles;317 const allTitles = Search._index.alltitles;318 const indexEntries = Search._index.indexentries;319 320 // Collect multiple result groups to be sorted separately and then ordered.321 // Each is an array of [docname, title, anchor, descr, score, filename].322 const normalResults = [];323 const nonMainIndexResults = [];324 325 _removeChildren(document.getElementById("search-progress"));326 327 const queryLower = query.toLowerCase().trim();328 for (const [title, foundTitles] of Object.entries(allTitles)) {329 if (title.toLowerCase().trim().includes(queryLower) && (queryLower.length >= title.length/2)) {330 for (const [file, id] of foundTitles) {331 let score = Math.round(100 * queryLower.length / title.length)332 normalResults.push([333 docNames[file],334 titles[file] !== title ? `${titles[file]} > ${title}` : title,335 id !== null ? "#" + id : "",336 null,337 score,338 filenames[file],339 ]);340 }341 }342 }343 344 // search for explicit entries in index directives345 for (const [entry, foundEntries] of Object.entries(indexEntries)) {346 if (entry.includes(queryLower) && (queryLower.length >= entry.length/2)) {347 for (const [file, id, isMain] of foundEntries) {348 const score = Math.round(100 * queryLower.length / entry.length);349 const result = [350 docNames[file],351 titles[file],352 id ? "#" + id : "",353 null,354 score,355 filenames[file],356 ];357 if (isMain) {358 normalResults.push(result);359 } else {360 nonMainIndexResults.push(result);361 }362 }363 }364 }365 366 // lookup as object367 objectTerms.forEach((term) =>368 normalResults.push(...Search.performObjectSearch(term, objectTerms))369 );370 371 // lookup as search terms in fulltext372 normalResults.push(...Search.performTermsSearch(searchTerms, excludedTerms));373 374 // let the scorer override scores with a custom scoring function375 if (Scorer.score) {376 normalResults.forEach((item) => (item[4] = Scorer.score(item)));377 nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item)));378 }379 380 // Sort each group of results by score and then alphabetically by name.381 normalResults.sort(_orderResultsByScoreThenName);382 nonMainIndexResults.sort(_orderResultsByScoreThenName);383 384 // Combine the result groups in (reverse) order.385 // Non-main index entries are typically arbitrary cross-references,386 // so display them after other results.387 let results = [...nonMainIndexResults, ...normalResults];388 389 // remove duplicate search results390 // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept391 let seen = new Set();392 results = results.reverse().reduce((acc, result) => {393 let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(',');394 if (!seen.has(resultStr)) {395 acc.push(result);396 seen.add(resultStr);397 }398 return acc;399 }, []);400 401 return results.reverse();402 },403 404 query: (query) => {405 const [searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms] = Search._parseQuery(query);406 const results = Search._performSearch(searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms);407 408 // for debugging409 //Search.lastresults = results.slice(); // a copy410 // console.info("search results:", Search.lastresults);411 412 // print the results413 _displayNextItem(results, results.length, searchTerms, highlightTerms);414 },415 416 /**417 * search for object names418 */419 performObjectSearch: (object, objectTerms) => {420 const filenames = Search._index.filenames;421 const docNames = Search._index.docnames;422 const objects = Search._index.objects;423 const objNames = Search._index.objnames;424 const titles = Search._index.titles;425 426 const results = [];427 428 const objectSearchCallback = (prefix, match) => {429 const name = match[4]430 const fullname = (prefix ? prefix + "." : "") + name;431 const fullnameLower = fullname.toLowerCase();432 if (fullnameLower.indexOf(object) < 0) return;433 434 let score = 0;435 const parts = fullnameLower.split(".");436 437 // check for different match types: exact matches of full name or438 // "last name" (i.e. last dotted part)439 if (fullnameLower === object || parts.slice(-1)[0] === object)440 score += Scorer.objNameMatch;441 else if (parts.slice(-1)[0].indexOf(object) > -1)442 score += Scorer.objPartialMatch; // matches in last name443 444 const objName = objNames[match[1]][2];445 const title = titles[match[0]];446 447 // If more than one term searched for, we require other words to be448 // found in the name/title/description449 const otherTerms = new Set(objectTerms);450 otherTerms.delete(object);451 if (otherTerms.size > 0) {452 const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase();453 if (454 [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0)455 )456 return;457 }458 459 let anchor = match[3];460 if (anchor === "") anchor = fullname;461 else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname;462 463 const descr = objName + _(", in ") + title;464 465 // add custom score for some objects according to scorer466 if (Scorer.objPrio.hasOwnProperty(match[2]))467 score += Scorer.objPrio[match[2]];468 else score += Scorer.objPrioDefault;469 470 results.push([471 docNames[match[0]],472 fullname,473 "#" + anchor,474 descr,475 score,476 filenames[match[0]],477 ]);478 };479 Object.keys(objects).forEach((prefix) =>480 objects[prefix].forEach((array) =>481 objectSearchCallback(prefix, array)482 )483 );484 return results;485 },486 487 /**488 * search for full-text terms in the index489 */490 performTermsSearch: (searchTerms, excludedTerms) => {491 // prepare search492 const terms = Search._index.terms;493 const titleTerms = Search._index.titleterms;494 const filenames = Search._index.filenames;495 const docNames = Search._index.docnames;496 const titles = Search._index.titles;497 498 const scoreMap = new Map();499 const fileMap = new Map();500 501 // perform the search on the required terms502 searchTerms.forEach((word) => {503 const files = [];504 const arr = [505 { files: terms[word], score: Scorer.term },506 { files: titleTerms[word], score: Scorer.title },507 ];508 // add support for partial matches509 if (word.length > 2) {510 const escapedWord = _escapeRegExp(word);511 if (!terms.hasOwnProperty(word)) {512 Object.keys(terms).forEach((term) => {513 if (term.match(escapedWord))514 arr.push({ files: terms[term], score: Scorer.partialTerm });515 });516 }517 if (!titleTerms.hasOwnProperty(word)) {518 Object.keys(titleTerms).forEach((term) => {519 if (term.match(escapedWord))520 arr.push({ files: titleTerms[term], score: Scorer.partialTitle });521 });522 }523 }524 525 // no match but word was a required one526 if (arr.every((record) => record.files === undefined)) return;527 528 // found search word in contents529 arr.forEach((record) => {530 if (record.files === undefined) return;531 532 let recordFiles = record.files;533 if (recordFiles.length === undefined) recordFiles = [recordFiles];534 files.push(...recordFiles);535 536 // set score for the word in each file537 recordFiles.forEach((file) => {538 if (!scoreMap.has(file)) scoreMap.set(file, {});539 scoreMap.get(file)[word] = record.score;540 });541 });542 543 // create the mapping544 files.forEach((file) => {545 if (!fileMap.has(file)) fileMap.set(file, [word]);546 else if (fileMap.get(file).indexOf(word) === -1) fileMap.get(file).push(word);547 });548 });549 550 // now check if the files don't contain excluded terms551 const results = [];552 for (const [file, wordList] of fileMap) {553 // check if all requirements are matched554 555 // as search terms with length < 3 are discarded556 const filteredTermCount = [...searchTerms].filter(557 (term) => term.length > 2558 ).length;559 if (560 wordList.length !== searchTerms.size &&561 wordList.length !== filteredTermCount562 )563 continue;564 565 // ensure that none of the excluded terms is in the search result566 if (567 [...excludedTerms].some(568 (term) =>569 terms[term] === file ||570 titleTerms[term] === file ||571 (terms[term] || []).includes(file) ||572 (titleTerms[term] || []).includes(file)573 )574 )575 break;576 577 // select one (max) score for the file.578 const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w]));579 // add result to the result list580 results.push([581 docNames[file],582 titles[file],583 "",584 null,585 score,586 filenames[file],587 ]);588 }589 return results;590 },591 592 /**593 * helper function to return a node containing the594 * search summary for a given text. keywords is a list595 * of stemmed words.596 */597 makeSearchSummary: (htmlText, keywords, anchor) => {598 const text = Search.htmlToText(htmlText, anchor);599 if (text === "") return null;600 601 const textLower = text.toLowerCase();602 const actualStartPosition = [...keywords]603 .map((k) => textLower.indexOf(k.toLowerCase()))604 .filter((i) => i > -1)605 .slice(-1)[0];606 const startWithContext = Math.max(actualStartPosition - 120, 0);607 608 const top = startWithContext === 0 ? "" : "...";609 const tail = startWithContext + 240 < text.length ? "..." : "";610 611 let summary = document.createElement("p");612 summary.classList.add("context");613 summary.textContent = top + text.substr(startWithContext, 240).trim() + tail;614 615 return summary;616 },617};618 619_ready(Search.init);620 