// Wrapper around lunr index searching system for pydoctor API objects // and function to format search results items into rederable HTML elements. // This file is meant to be used as a library for the pydoctor search bar (search.js) as well as // provide a hackable inferface to integrate API docs searching into other platforms, i.e. provide a // "Search in API docs" option from Read The Docs search page. // Depends on ajax.js, bundled with pydoctor. // Other required ressources like lunr.js, searchindex.json and all-documents.html are passed as URL // to functions. This makes the code reusable outside of pydoctor build directory. // Implementation note: Searches are designed to be launched synchronously, if lunrSearch() is called sucessively (while already running), // old promise will never resolves and the searhc worker will be restarted. // Hacky way to make the worker code inline with the rest of the source file handling the search. // Worker message params are the following: // - query: string // - indexJSONData: dict // - defaultFields: list of strings // - autoWildcard: boolean let _lunrWorkerCode = ` // The lunr.js code will be inserted here. onmessage = (message) => { if (!message.data.query) { throw new Error('No search query provided.'); } if (!message.data.indexJSONData) { throw new Error('No index data provided.'); } if (!message.data.defaultFields) { throw new Error('No default fields provided.'); } if (!message.data.hasOwnProperty('autoWildcard')){ throw new Error('No value for auto wildcard provided.'); } // Create index let index = lunr.Index.load(message.data.indexJSONData); // Declare query function building function _queryfn(_query){ // _query is the Query object // Edit the parsed query clauses that are applicable for all fields (default) in order // to remove the field 'kind' from the clause since this it's only useful when specifically requested. var parser = new lunr.QueryParser(message.data.query, _query) parser.parse() var hasTraillingWildcard = false; _query.clauses.forEach(clause => { if (clause.fields == _query.allFields){ // we change the query fields when they are applicable to all fields // to a list of predefined fields because we might include additional filters (like kind:) // which should not be matched by default. clause.fields = message.data.defaultFields; } // clause.wildcard is actually always NONE due to https://github.com/olivernn/lunr.js/issues/495 // But this works... if (clause.term.slice(-1) == '*'){ // we want to avoid the auto wildcard system only if a trailling wildcard is already added // not if a leading wildcard exists hasTraillingWildcard = true } }); // Auto wilcard feature, see issue https://github.com/twisted/pydoctor/issues/648 var new_clauses = []; if ((message.data.autoWildcard == true) && (hasTraillingWildcard == false)){ _query.clauses.forEach(clause => { // Setting clause.wildcard is useless. // But this works... let new_clause = {...clause} new_clause.term = new_clause.term + '*' clause.boost = 2 new_clause.boost = 0 new_clauses.push(new_clause) }); } new_clauses.forEach(clause => { _query.clauses.push(clause) }); console.log('Parsed query:') console.dir(_query.clauses) } // Launch the search let results = index.query(_queryfn) // Post message with results postMessage({'results':results}); }; `; // Adapted from https://stackoverflow.com/a/44137284 // Override worker methods to detect termination and count message posting and restart() method. // This allows some optimizations since the worker doesn't need to be restarted when it hasn't been used. function _makeWorkerSmart(workerURL) { // make normal worker var worker = new Worker(workerURL); // assume that it's running from the start worker.terminated = false; worker.postMessageCount = 0; // count the number of times postMessage() is called worker.postMessage = function() { this.postMessageCount = this.postMessageCount + 1; // normal post message return Worker.prototype.postMessage.apply(this, arguments); } // sets terminated to true worker.terminate = function() { if (this.terminated===true){return;} this.terminated = true; // normal terminate return Worker.prototype.terminate.apply(this, arguments); } // creates NEW WORKER with the same URL as itself, terminate worker first. worker.restart = function() { this.terminate(); return _makeWorkerSmart(workerURL); } return worker; } var _searchWorker = null /** * The searchEventsEnv Document variable let's let caller register a event listener "searchStarted" for sending * a signal when the search actually starts, could be up to 0.2 or 0.3 secs ater user finished typing. */ let searchEventsEnv = document.implementation.createHTMLDocument( 'This is a document to popagate search related events, we avoid using "document" for performance reasons.'); // there is a difference in abortSearch() vs restartSearchWorker(). // abortSearch() triggers a abortSearch event, which have a effect on searches that are not yet running in workers. // whereas restartSearchWorker() which kills the worker if it's in use, but does not abort search that is not yet posted to the worker. function abortSearch(){ searchEventsEnv.dispatchEvent(new CustomEvent('abortSearch', {})); } // Kills and restarts search worker (if needed). function restartSearchWorker() { var w = _searchWorker; if (w!=null){ if (w.postMessageCount>0){ // the worker has been used, it has to be restarted // TODO: Actually it needs to be restarted only if it's running a search right now. // Otherwise we can reuse the same worker, but that's not a very big deal in this context. w = w.restart(); } // Else, the worker has never been used, it can be returned as is. // This can happens when typing fast with a very large index JSON to load. } _searchWorker = w; } function _getWorkerPromise(lunJsSourceCode){ // -> Promise of a fresh worker to run a query. let promise = new Promise((resolve, reject) => { // Do the search business, wrap the process inside an inline Worker. // This is a hack such that the UI can refresh during the search. if (_searchWorker===null){ // Create only one blob and URL. let lunrWorkerCode = lunJsSourceCode + _lunrWorkerCode; let _workerBlob = new Blob([lunrWorkerCode], {type: 'text/javascript'}); let _workerObjectURL = window.URL.createObjectURL(_workerBlob); _searchWorker = _makeWorkerSmart(_workerObjectURL) } else{ restartSearchWorker(); } resolve(_searchWorker); }); return promise } /** * Launch a search and get a promise of results. One search can be lauch at a time only. * Old promise never resolves if calling lunrSearch() again while already running. * @param query: Query string. * @param indexURL: URL pointing to the Lunr search index, generated by pydoctor. * @param defaultFields: List of strings: default fields to apply to query clauses when none is specified. ["name", "names", "qname"] for instance. * @param lunrJsURL: URL pointing to a copy of lunr.js. * @param searchDelay: Number of miliseconds to wait before actually launching the query. This is useful to set for "search as you type" kind of search box * because it let a chance to users to continue typing without triggering useless searches (because previous search is aborted on launching a new one). * @param autoWildcard: Whether to automatically append wildcards to all query clauses when no wildcard is already specified. boolean. */ function lunrSearch(query, indexURL, defaultFields, lunrJsURL, searchDelay, autoWildcard){ // Abort ongoing search abortSearch(); // Register abort procedure. var _aborted = false; searchEventsEnv.addEventListener('abortSearch', (ev) => { _aborted = true; searchEventsEnv.removeEventListener('abortSearch', this); }); // Pref: // Because this function can be called a lot of times in a very few moments, // Actually launch search after a delay to let a chance to users to continue typing, // which would trigger a search abort event, which would avoid wasting a worker // for a search that is not wanted anymore. return new Promise((_resolve, _reject) => { setTimeout(() => { _resolve( _getIndexDataPromise(indexURL).then((lunrIndexData) => { // Include lunr.js source inside the worker such that it has no dependencies. return httpGetPromise(lunrJsURL).then((responseText) => { // Do the search business, wrap the process inside an inline Worker. // This is a hack such that the UI can refresh during the search. return _getWorkerPromise(responseText).then((worker) => { let promise = new Promise((resolve, reject) => { worker.onmessage = (message) => { if (!message.data.results){ reject("No data received from worker"); } else{ console.log("Got result from worker:") console.dir(message.data.results) resolve(message.data.results) } } worker.onerror = function(error) { reject(error); }; }); let _msgData = { 'query': query, 'indexJSONData': lunrIndexData, 'defaultFields': defaultFields, 'autoWildcard': autoWildcard, } if (!_aborted){ console.log(`Posting query "${query}" to worker:`) console.dir(_msgData) worker.postMessage(_msgData); searchEventsEnv.dispatchEvent( new CustomEvent("searchStarted", {'query':query}) ); } return promise }); }); }) );}, searchDelay); }); } /** * @param results: list of lunr.Index~Result. * @param allDocumentsURL: URL pointing to all-documents.html, generated by pydoctor. * @returns: Promise of a list of HTMLElement corresponding to the all-documents.html * list elements matching your search results. */ function fetchResultsData(results, allDocumentsURL){ return _getAllDocumentsPromise(allDocumentsURL).then((allDocuments) => { // Look for results data in parsed all-documents.html return _asyncFor(results, (result) => { // Find the result model row data. var dobj = allDocuments.getElementById(result.ref); if (!dobj){ throw new Error("Cannot find document ID: " + result.ref); } // Return result data return dobj; }) }) } /** * Transform list item as in all-documents.html into a formatted search result row. */ function buildSearchResult(dobj) { // Build one result item var tr = document.createElement('tr'), kindtd = document.createElement('td'), contenttd = document.createElement('td'), article = document.createElement('article'), header = document.createElement('header'), section = document.createElement('section'), code = document.createElement('code'), a = document.createElement('a'), p = document.createElement('p'); p.innerHTML = dobj.querySelector('.summary').innerHTML; a.setAttribute('href', dobj.querySelector('.url').innerHTML); a.setAttribute('class', 'internal-link'); a.innerHTML = dobj.querySelector('.fullName').innerHTML; let kind_value = dobj.querySelector('.kind').innerHTML; let type_value = dobj.querySelector('.type').innerHTML; // Adding '()' on functions and methods if (type_value.endsWith("Function")){ a.innerHTML = a.innerHTML + '()'; } kindtd.innerHTML = kind_value; // Putting everything together tr.appendChild(kindtd); tr.appendChild(contenttd); contenttd.appendChild(article); article.appendChild(header); article.appendChild(section); header.appendChild(code); code.appendChild(a); section.appendChild(p); // Set kind as the CSS class of the kind td tag let ob_css_class = dobj.querySelector('.kind').innerHTML.toLowerCase().replace(' ', ''); kindtd.setAttribute('class', ob_css_class); // Set private if (dobj.querySelector('.privacy').innerHTML.includes('PRIVATE')){ tr.setAttribute('class', 'private'); } return tr; } // This gives the UI the opportunity to refresh while we're iterating over a large list. function _asyncFor(iterable, callback) { // -> Promise of List of results returned by callback const promise_global = new Promise((resolve_global, reject_global) => { let promises = []; iterable.forEach((element) => { promises.push(new Promise((resolve, _reject) => { setTimeout(() => { try{ resolve(callback(element)); } catch (error){ _reject(error); } }, 0); })); }); Promise.all(promises).then((results) =>{ resolve_global(results); }).catch((err) => { reject_global(err); }); }); return promise_global; } // Cache indexes JSON data since it takes a little bit of time to load JSON into stuctured data var _indexDataCache = {}; function _getIndexDataPromise(indexURL) { // -> Promise of a structured data for the lunr Index. if (!_indexDataCache[indexURL]){ return httpGetPromise(indexURL).then((responseText) => { _indexDataCache[indexURL] = JSON.parse(responseText) return (_indexDataCache[indexURL]); }); } else{ return new Promise((_resolve, _reject) => { _resolve(_indexDataCache[indexURL]); }); } } // Cache Document object var _allDocumentsCache = {}; function _getAllDocumentsPromise(allDocumentsURL) { // -> Promise of the all-documents.html Document object. if (!_allDocumentsCache[allDocumentsURL]){ return httpGetPromise(allDocumentsURL).then((responseText) => { let _parser = new self.DOMParser(); _allDocumentsCache[allDocumentsURL] = _parser.parseFromString(responseText, "text/html"); return (_allDocumentsCache[allDocumentsURL]); }); } else{ return new Promise((_resolve, _reject) => { _resolve(_allDocumentsCache[allDocumentsURL]); }); } }