371 lines
15 KiB
JavaScript
371 lines
15 KiB
JavaScript
// 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]);
|
|
});
|
|
}
|
|
}
|