You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
3119 lines
99 KiB
JavaScript
3119 lines
99 KiB
JavaScript
/**
|
|
* elasticlunr - http://weixsong.github.io
|
|
* Lightweight full-text search engine in Javascript for browser search and offline search. - 0.9.5
|
|
*
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
* MIT Licensed
|
|
* @license
|
|
*/
|
|
(function () {
|
|
/*!
|
|
* elasticlunr.js
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* Convenience function for instantiating a new elasticlunr index and configuring it
|
|
* with the default pipeline functions and the passed config function.
|
|
*
|
|
* When using this convenience function a new index will be created with the
|
|
* following functions already in the pipeline:
|
|
*
|
|
* 1. elasticlunr.trimmer - trim non-word character
|
|
* 2. elasticlunr.StopWordFilter - filters out any stop words before they enter the
|
|
* index
|
|
* 3. elasticlunr.stemmer - stems the tokens before entering the index.
|
|
*
|
|
*
|
|
* Example:
|
|
*
|
|
* var idx = elasticlunr(function () {
|
|
* this.addField('id');
|
|
* this.addField('title');
|
|
* this.addField('body');
|
|
*
|
|
* //this.setRef('id'); // default ref is 'id'
|
|
*
|
|
* this.pipeline.add(function () {
|
|
* // some custom pipeline function
|
|
* });
|
|
* });
|
|
*
|
|
* idx.addDoc({
|
|
* id: 1,
|
|
* title: 'Oracle released database 12g',
|
|
* body: 'Yestaday, Oracle has released their latest database, named 12g, more robust. this product will increase Oracle profit.'
|
|
* });
|
|
*
|
|
* idx.addDoc({
|
|
* id: 2,
|
|
* title: 'Oracle released annual profit report',
|
|
* body: 'Yestaday, Oracle has released their annual profit report of 2015, total profit is 12.5 Billion.'
|
|
* });
|
|
*
|
|
* # simple search
|
|
* idx.search('oracle database');
|
|
*
|
|
* # search with query-time boosting
|
|
* idx.search('oracle database', {fields: {title: {boost: 2}, body: {boost: 1}}});
|
|
*
|
|
* @param {Function} config A function that will be called with the new instance
|
|
* of the elasticlunr.Index as both its context and first parameter. It can be used to
|
|
* customize the instance of new elasticlunr.Index.
|
|
* @namespace
|
|
* @module
|
|
* @return {elasticlunr.Index}
|
|
*
|
|
*/
|
|
const elasticlunr = function (config) {
|
|
const idx = new elasticlunr.Index();
|
|
|
|
idx.pipeline.add(
|
|
elasticlunr.trimmer,
|
|
elasticlunr.stopWordFilter,
|
|
elasticlunr.stemmer
|
|
);
|
|
|
|
if (config) config.call(idx, idx);
|
|
|
|
return idx;
|
|
};
|
|
|
|
elasticlunr.version = '0.9.5';
|
|
|
|
// only used this to make elasticlunr.js compatible with lunr-languages
|
|
// this is a trick to define a global alias of elasticlunr
|
|
lunr = elasticlunr;
|
|
|
|
/*!
|
|
* elasticlunr.utils
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* A namespace containing utils for the rest of the elasticlunr library
|
|
*/
|
|
elasticlunr.utils = {};
|
|
|
|
/**
|
|
* Print a warning message to the console.
|
|
*
|
|
* @param {String} message The message to be printed.
|
|
* @memberOf Utils
|
|
*/
|
|
elasticlunr.utils.warn = (function (global) {
|
|
return function (message) {
|
|
if (global.console && console.warn) {
|
|
console.warn(message);
|
|
}
|
|
};
|
|
})(this);
|
|
|
|
/**
|
|
* Convert an object to string.
|
|
*
|
|
* In the case of `null` and `undefined` the function returns
|
|
* an empty string, in all other cases the result of calling
|
|
* `toString` on the passed object is returned.
|
|
*
|
|
* @param {object} obj The object to convert to a string.
|
|
* @return {String} string representation of the passed object.
|
|
* @memberOf Utils
|
|
*/
|
|
elasticlunr.utils.toString = function (obj) {
|
|
if (obj === void 0 || obj === null) {
|
|
return '';
|
|
}
|
|
|
|
return obj.toString();
|
|
};
|
|
/*!
|
|
* elasticlunr.EventEmitter
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.EventEmitter is an event emitter for elasticlunr.
|
|
* It manages adding and removing event handlers and triggering events and their handlers.
|
|
*
|
|
* Each event could has multiple corresponding functions,
|
|
* these functions will be called as the sequence that they are added into the event.
|
|
*
|
|
* @constructor
|
|
*/
|
|
elasticlunr.EventEmitter = function () {
|
|
this.events = {};
|
|
};
|
|
|
|
/**
|
|
* Binds a handler function to a specific event(s).
|
|
*
|
|
* Can bind a single function to many different events in one call.
|
|
*
|
|
* @param {String} [eventName] The name(s) of events to bind this function to.
|
|
* @param {Function} fn The function to call when an event is fired.
|
|
* @memberOf EventEmitter
|
|
*/
|
|
elasticlunr.EventEmitter.prototype.addListener = function () {
|
|
const args = Array.prototype.slice.call(arguments);
|
|
const fn = args.pop();
|
|
const names = args;
|
|
|
|
if (typeof fn !== 'function')
|
|
throw new TypeError('last argument must be a function');
|
|
|
|
names.forEach(function (name) {
|
|
if (!this.hasHandler(name)) this.events[name] = [];
|
|
this.events[name].push(fn);
|
|
}, this);
|
|
};
|
|
|
|
/**
|
|
* Removes a handler function from a specific event.
|
|
*
|
|
* @param {String} eventName The name of the event to remove this function from.
|
|
* @param {Function} fn The function to remove from an event.
|
|
* @memberOf EventEmitter
|
|
*/
|
|
elasticlunr.EventEmitter.prototype.removeListener = function (name, fn) {
|
|
if (!this.hasHandler(name)) return;
|
|
|
|
const fnIndex = this.events[name].indexOf(fn);
|
|
if (fnIndex === -1) return;
|
|
|
|
this.events[name].splice(fnIndex, 1);
|
|
|
|
if (this.events[name].length === 0) delete this.events[name];
|
|
};
|
|
|
|
/**
|
|
* Call all functions that bounded to the given event.
|
|
*
|
|
* Additional data can be passed to the event handler as arguments to `emit`
|
|
* after the event name.
|
|
*
|
|
* @param {String} eventName The name of the event to emit.
|
|
* @memberOf EventEmitter
|
|
*/
|
|
elasticlunr.EventEmitter.prototype.emit = function (name) {
|
|
if (!this.hasHandler(name)) return;
|
|
|
|
const args = Array.prototype.slice.call(arguments, 1);
|
|
|
|
this.events[name].forEach(function (fn) {
|
|
fn.apply(undefined, args);
|
|
}, this);
|
|
};
|
|
|
|
/**
|
|
* Checks whether a handler has ever been stored against an event.
|
|
*
|
|
* @param {String} eventName The name of the event to check.
|
|
* @private
|
|
* @memberOf EventEmitter
|
|
*/
|
|
elasticlunr.EventEmitter.prototype.hasHandler = function (name) {
|
|
return name in this.events;
|
|
};
|
|
/*!
|
|
* elasticlunr.tokenizer
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* A function for splitting a string into tokens.
|
|
* Currently English is supported as default.
|
|
* Uses `elasticlunr.tokenizer.seperator` to split strings, you could change
|
|
* the value of this property to set how you want strings are split into tokens.
|
|
* IMPORTANT: use elasticlunr.tokenizer.seperator carefully, if you are not familiar with
|
|
* text process, then you'd better not change it.
|
|
*
|
|
* @module
|
|
* @param {String} str The string that you want to tokenize.
|
|
* @see elasticlunr.tokenizer.seperator
|
|
* @return {Array}
|
|
*/
|
|
elasticlunr.tokenizer = function (str) {
|
|
if (!arguments.length || str === null || str === undefined) return [];
|
|
if (Array.isArray(str)) {
|
|
let arr = str.filter(function (token) {
|
|
if (token === null || token === undefined) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
arr = arr.map(function (t) {
|
|
return elasticlunr.utils.toString(t).toLowerCase();
|
|
});
|
|
|
|
let out = [];
|
|
arr.forEach(function (item) {
|
|
const tokens = item.split(elasticlunr.tokenizer.seperator);
|
|
out = out.concat(tokens);
|
|
}, this);
|
|
|
|
return out;
|
|
}
|
|
|
|
return str
|
|
.toString()
|
|
.trim()
|
|
.toLowerCase()
|
|
.split(elasticlunr.tokenizer.seperator);
|
|
};
|
|
|
|
/**
|
|
* Default string seperator.
|
|
*/
|
|
elasticlunr.tokenizer.defaultSeperator = /[\s-]+/;
|
|
|
|
/**
|
|
* The sperator used to split a string into tokens. Override this property to change the behaviour of
|
|
* `elasticlunr.tokenizer` behaviour when tokenizing strings. By default this splits on whitespace and hyphens.
|
|
*
|
|
* @static
|
|
* @see elasticlunr.tokenizer
|
|
*/
|
|
elasticlunr.tokenizer.seperator = elasticlunr.tokenizer.defaultSeperator;
|
|
|
|
/**
|
|
* Set up customized string seperator
|
|
*
|
|
* @param {Object} sep The customized seperator that you want to use to tokenize a string.
|
|
*/
|
|
elasticlunr.tokenizer.setSeperator = function (sep) {
|
|
if (sep !== null && sep !== undefined && typeof sep === 'object') {
|
|
elasticlunr.tokenizer.seperator = sep;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reset string seperator
|
|
*
|
|
*/
|
|
elasticlunr.tokenizer.resetSeperator = function () {
|
|
elasticlunr.tokenizer.seperator = elasticlunr.tokenizer.defaultSeperator;
|
|
};
|
|
|
|
/**
|
|
* Get string seperator
|
|
*
|
|
*/
|
|
elasticlunr.tokenizer.getSeperator = function () {
|
|
return elasticlunr.tokenizer.seperator;
|
|
};
|
|
/*!
|
|
* elasticlunr.Pipeline
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.Pipelines maintain an ordered list of functions to be applied to
|
|
* both documents tokens and query tokens.
|
|
*
|
|
* An instance of elasticlunr.Index will contain a pipeline
|
|
* with a trimmer, a stop word filter, an English stemmer. Extra
|
|
* functions can be added before or after either of these functions or these
|
|
* default functions can be removed.
|
|
*
|
|
* When run the pipeline, it will call each function in turn.
|
|
*
|
|
* The output of the functions in the pipeline will be passed to the next function
|
|
* in the pipeline. To exclude a token from entering the index the function
|
|
* should return undefined, the rest of the pipeline will not be called with
|
|
* this token.
|
|
*
|
|
* For serialisation of pipelines to work, all functions used in an instance of
|
|
* a pipeline should be registered with elasticlunr.Pipeline. Registered functions can
|
|
* then be loaded. If trying to load a serialised pipeline that uses functions
|
|
* that are not registered an error will be thrown.
|
|
*
|
|
* If not planning on serialising the pipeline then registering pipeline functions
|
|
* is not necessary.
|
|
*
|
|
* @constructor
|
|
*/
|
|
elasticlunr.Pipeline = function () {
|
|
this._queue = [];
|
|
};
|
|
|
|
elasticlunr.Pipeline.registeredFunctions = {};
|
|
|
|
/**
|
|
* Register a function in the pipeline.
|
|
*
|
|
* Functions that are used in the pipeline should be registered if the pipeline
|
|
* needs to be serialised, or a serialised pipeline needs to be loaded.
|
|
*
|
|
* Registering a function does not add it to a pipeline, functions must still be
|
|
* added to instances of the pipeline for them to be used when running a pipeline.
|
|
*
|
|
* @param {Function} fn The function to register.
|
|
* @param {String} label The label to register this function with
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.registerFunction = function (fn, label) {
|
|
if (label in elasticlunr.Pipeline.registeredFunctions) {
|
|
elasticlunr.utils.warn(
|
|
'Overwriting existing registered function: ' + label
|
|
);
|
|
}
|
|
|
|
fn.label = label;
|
|
elasticlunr.Pipeline.registeredFunctions[label] = fn;
|
|
};
|
|
|
|
/**
|
|
* Get a registered function in the pipeline.
|
|
*
|
|
* @param {String} label The label of registered function.
|
|
* @return {Function}
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.getRegisteredFunction = function (label) {
|
|
if (label in elasticlunr.Pipeline.registeredFunctions !== true) {
|
|
return null;
|
|
}
|
|
|
|
return elasticlunr.Pipeline.registeredFunctions[label];
|
|
};
|
|
|
|
/**
|
|
* Warns if the function is not registered as a Pipeline function.
|
|
*
|
|
* @param {Function} fn The function to check for.
|
|
* @private
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.warnIfFunctionNotRegistered = function (fn) {
|
|
const isRegistered = fn.label && fn.label in this.registeredFunctions;
|
|
|
|
if (!isRegistered) {
|
|
elasticlunr.utils.warn(
|
|
'Function is not registered with pipeline. This may cause problems when serialising the index.\n',
|
|
fn
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Loads a previously serialised pipeline.
|
|
*
|
|
* All functions to be loaded must already be registered with elasticlunr.Pipeline.
|
|
* If any function from the serialised data has not been registered then an
|
|
* error will be thrown.
|
|
*
|
|
* @param {Object} serialised The serialised pipeline to load.
|
|
* @return {elasticlunr.Pipeline}
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.load = function (serialised) {
|
|
const pipeline = new elasticlunr.Pipeline();
|
|
|
|
serialised.forEach(function (fnName) {
|
|
const fn = elasticlunr.Pipeline.getRegisteredFunction(fnName);
|
|
|
|
if (fn) {
|
|
pipeline.add(fn);
|
|
} else {
|
|
throw new Error('Cannot load un-registered function: ' + fnName);
|
|
}
|
|
});
|
|
|
|
return pipeline;
|
|
};
|
|
|
|
/**
|
|
* Adds new functions to the end of the pipeline.
|
|
*
|
|
* Logs a warning if the function has not been registered.
|
|
*
|
|
* @param {Function} functions Any number of functions to add to the pipeline.
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.add = function () {
|
|
const fns = Array.prototype.slice.call(arguments);
|
|
|
|
fns.forEach(function (fn) {
|
|
elasticlunr.Pipeline.warnIfFunctionNotRegistered(fn);
|
|
this._queue.push(fn);
|
|
}, this);
|
|
};
|
|
|
|
/**
|
|
* Adds a single function after a function that already exists in the
|
|
* pipeline.
|
|
*
|
|
* Logs a warning if the function has not been registered.
|
|
* If existingFn is not found, throw an Exception.
|
|
*
|
|
* @param {Function} existingFn A function that already exists in the pipeline.
|
|
* @param {Function} newFn The new function to add to the pipeline.
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.after = function (existingFn, newFn) {
|
|
elasticlunr.Pipeline.warnIfFunctionNotRegistered(newFn);
|
|
|
|
const pos = this._queue.indexOf(existingFn);
|
|
if (pos === -1) {
|
|
throw new Error('Cannot find existingFn');
|
|
}
|
|
|
|
this._queue.splice(pos + 1, 0, newFn);
|
|
};
|
|
|
|
/**
|
|
* Adds a single function before a function that already exists in the
|
|
* pipeline.
|
|
*
|
|
* Logs a warning if the function has not been registered.
|
|
* If existingFn is not found, throw an Exception.
|
|
*
|
|
* @param {Function} existingFn A function that already exists in the pipeline.
|
|
* @param {Function} newFn The new function to add to the pipeline.
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.before = function (existingFn, newFn) {
|
|
elasticlunr.Pipeline.warnIfFunctionNotRegistered(newFn);
|
|
|
|
const pos = this._queue.indexOf(existingFn);
|
|
if (pos === -1) {
|
|
throw new Error('Cannot find existingFn');
|
|
}
|
|
|
|
this._queue.splice(pos, 0, newFn);
|
|
};
|
|
|
|
/**
|
|
* Removes a function from the pipeline.
|
|
*
|
|
* @param {Function} fn The function to remove from the pipeline.
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.remove = function (fn) {
|
|
const pos = this._queue.indexOf(fn);
|
|
if (pos === -1) {
|
|
return;
|
|
}
|
|
|
|
this._queue.splice(pos, 1);
|
|
};
|
|
|
|
/**
|
|
* Runs the current list of functions that registered in the pipeline against the
|
|
* input tokens.
|
|
*
|
|
* @param {Array} tokens The tokens to run through the pipeline.
|
|
* @return {Array}
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.run = function (tokens) {
|
|
const out = [];
|
|
const tokenLength = tokens.length;
|
|
const pipelineLength = this._queue.length;
|
|
|
|
for (let i = 0; i < tokenLength; i++) {
|
|
let token = tokens[i];
|
|
|
|
for (let j = 0; j < pipelineLength; j++) {
|
|
token = this._queue[j](token, i, tokens);
|
|
if (token === void 0 || token === null) break;
|
|
}
|
|
|
|
if (token !== void 0 && token !== null) out.push(token);
|
|
}
|
|
|
|
return out;
|
|
};
|
|
|
|
/**
|
|
* Resets the pipeline by removing any existing processors.
|
|
*
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.reset = function () {
|
|
this._queue = [];
|
|
};
|
|
|
|
/**
|
|
* Get the pipeline if user want to check the pipeline.
|
|
*
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.get = function () {
|
|
return this._queue;
|
|
};
|
|
|
|
/**
|
|
* Returns a representation of the pipeline ready for serialisation.
|
|
* Only serialize pipeline function's name. Not storing function, so when
|
|
* loading the archived JSON index file, corresponding pipeline function is
|
|
* added by registered function of elasticlunr.Pipeline.registeredFunctions
|
|
*
|
|
* Logs a warning if the function has not been registered.
|
|
*
|
|
* @return {Array}
|
|
* @memberOf Pipeline
|
|
*/
|
|
elasticlunr.Pipeline.prototype.toJSON = function () {
|
|
return this._queue.map(function (fn) {
|
|
elasticlunr.Pipeline.warnIfFunctionNotRegistered(fn);
|
|
return fn.label;
|
|
});
|
|
};
|
|
/*!
|
|
* elasticlunr.Index
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.Index is object that manages a search index. It contains the indexes
|
|
* and stores all the tokens and document lookups. It also provides the main
|
|
* user facing API for the library.
|
|
*
|
|
* @constructor
|
|
*/
|
|
elasticlunr.Index = function () {
|
|
this._fields = [];
|
|
this._ref = 'id';
|
|
this.pipeline = new elasticlunr.Pipeline();
|
|
this.documentStore = new elasticlunr.DocumentStore();
|
|
this.index = {};
|
|
this.eventEmitter = new elasticlunr.EventEmitter();
|
|
this._idfCache = {};
|
|
|
|
this.on(
|
|
'add',
|
|
'remove',
|
|
'update',
|
|
function () {
|
|
this._idfCache = {};
|
|
}.bind(this)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Bind a handler to events being emitted by the index.
|
|
*
|
|
* The handler can be bound to many events at the same time.
|
|
*
|
|
* @param {String} [eventName] The name(s) of events to bind the function to.
|
|
* @param {Function} fn The serialised set to load.
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.on = function () {
|
|
const args = Array.prototype.slice.call(arguments);
|
|
return this.eventEmitter.addListener.apply(this.eventEmitter, args);
|
|
};
|
|
|
|
/**
|
|
* Removes a handler from an event being emitted by the index.
|
|
*
|
|
* @param {String} eventName The name of events to remove the function from.
|
|
* @param {Function} fn The serialised set to load.
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.off = function (name, fn) {
|
|
return this.eventEmitter.removeListener(name, fn);
|
|
};
|
|
|
|
/**
|
|
* Loads a previously serialised index.
|
|
*
|
|
* Issues a warning if the index being imported was serialised
|
|
* by a different version of elasticlunr.
|
|
*
|
|
* @param {Object} serialisedData The serialised set to load.
|
|
* @return {elasticlunr.Index}
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.load = function (serialisedData) {
|
|
if (serialisedData.version !== elasticlunr.version) {
|
|
elasticlunr.utils.warn(
|
|
'version mismatch: current ' +
|
|
elasticlunr.version +
|
|
' importing ' +
|
|
serialisedData.version
|
|
);
|
|
}
|
|
|
|
const idx = new this();
|
|
|
|
idx._fields = serialisedData.fields;
|
|
idx._ref = serialisedData.ref;
|
|
idx.documentStore = elasticlunr.DocumentStore.load(
|
|
serialisedData.documentStore
|
|
);
|
|
idx.pipeline = elasticlunr.Pipeline.load(serialisedData.pipeline);
|
|
idx.index = {};
|
|
for (const field in serialisedData.index) {
|
|
idx.index[field] = elasticlunr.InvertedIndex.load(
|
|
serialisedData.index[field]
|
|
);
|
|
}
|
|
|
|
return idx;
|
|
};
|
|
|
|
/**
|
|
* Adds a field to the list of fields that will be searchable within documents in the index.
|
|
*
|
|
* Remember that inner index is build based on field, which means each field has one inverted index.
|
|
*
|
|
* Fields should be added before any documents are added to the index, fields
|
|
* that are added after documents are added to the index will only apply to new
|
|
* documents added to the index.
|
|
*
|
|
* @param {String} fieldName The name of the field within the document that should be indexed
|
|
* @return {elasticlunr.Index}
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.addField = function (fieldName) {
|
|
this._fields.push(fieldName);
|
|
this.index[fieldName] = new elasticlunr.InvertedIndex();
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Sets the property used to uniquely identify documents added to the index,
|
|
* by default this property is 'id'.
|
|
*
|
|
* This should only be changed before adding documents to the index, changing
|
|
* the ref property without resetting the index can lead to unexpected results.
|
|
*
|
|
* @param {String} refName The property to use to uniquely identify the
|
|
* documents in the index.
|
|
* @param {Boolean} emitEvent Whether to emit add events, defaults to true
|
|
* @return {elasticlunr.Index}
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.setRef = function (refName) {
|
|
this._ref = refName;
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
*
|
|
* Set if the JSON format original documents are save into elasticlunr.DocumentStore
|
|
*
|
|
* Defaultly save all the original JSON documents.
|
|
*
|
|
* @param {Boolean} save Whether to save the original JSON documents.
|
|
* @return {elasticlunr.Index}
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.saveDocument = function (save) {
|
|
this.documentStore = new elasticlunr.DocumentStore(save);
|
|
return this;
|
|
};
|
|
|
|
/**
|
|
* Add a JSON format document to the index.
|
|
*
|
|
* This is the way new documents enter the index, this function will run the
|
|
* fields from the document through the index's pipeline and then add it to
|
|
* the index, it will then show up in search results.
|
|
*
|
|
* An 'add' event is emitted with the document that has been added and the index
|
|
* the document has been added to. This event can be silenced by passing false
|
|
* as the second argument to add.
|
|
*
|
|
* @param {Object} doc The JSON format document to add to the index.
|
|
* @param {Boolean} emitEvent Whether or not to emit events, default true.
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.addDoc = function (doc, emitEvent) {
|
|
if (!doc) return;
|
|
var emitEvent = emitEvent === undefined ? true : emitEvent;
|
|
|
|
const docRef = doc[this._ref];
|
|
|
|
this.documentStore.addDoc(docRef, doc);
|
|
this._fields.forEach(function (field) {
|
|
const fieldTokens = this.pipeline.run(elasticlunr.tokenizer(doc[field]));
|
|
this.documentStore.addFieldLength(docRef, field, fieldTokens.length);
|
|
|
|
const tokenCount = {};
|
|
fieldTokens.forEach(function (token) {
|
|
if (token in tokenCount) tokenCount[token] += 1;
|
|
else tokenCount[token] = 1;
|
|
}, this);
|
|
|
|
for (const token in tokenCount) {
|
|
let termFrequency = tokenCount[token];
|
|
termFrequency = Math.sqrt(termFrequency);
|
|
this.index[field].addToken(token, { ref: docRef, tf: termFrequency });
|
|
}
|
|
}, this);
|
|
|
|
if (emitEvent) this.eventEmitter.emit('add', doc, this);
|
|
};
|
|
|
|
/**
|
|
* Removes a document from the index by doc ref.
|
|
*
|
|
* To make sure documents no longer show up in search results they can be
|
|
* removed from the index using this method.
|
|
*
|
|
* A 'remove' event is emitted with the document that has been removed and the index
|
|
* the document has been removed from. This event can be silenced by passing false
|
|
* as the second argument to remove.
|
|
*
|
|
* If user setting DocumentStore not storing the documents, then remove doc by docRef is not allowed.
|
|
*
|
|
* @param {String|Integer} docRef The document ref to remove from the index.
|
|
* @param {Boolean} emitEvent Whether to emit remove events, defaults to true
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.removeDocByRef = function (docRef, emitEvent) {
|
|
if (!docRef) return;
|
|
if (this.documentStore.isDocStored() === false) {
|
|
return;
|
|
}
|
|
|
|
if (!this.documentStore.hasDoc(docRef)) return;
|
|
const doc = this.documentStore.getDoc(docRef);
|
|
this.removeDoc(doc, false);
|
|
};
|
|
|
|
/**
|
|
* Removes a document from the index.
|
|
* This remove operation could work even the original doc is not store in the DocumentStore.
|
|
*
|
|
* To make sure documents no longer show up in search results they can be
|
|
* removed from the index using this method.
|
|
*
|
|
* A 'remove' event is emitted with the document that has been removed and the index
|
|
* the document has been removed from. This event can be silenced by passing false
|
|
* as the second argument to remove.
|
|
*
|
|
*
|
|
* @param {Object} doc The document ref to remove from the index.
|
|
* @param {Boolean} emitEvent Whether to emit remove events, defaults to true
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.removeDoc = function (doc, emitEvent) {
|
|
if (!doc) return;
|
|
|
|
var emitEvent = emitEvent === undefined ? true : emitEvent;
|
|
|
|
const docRef = doc[this._ref];
|
|
if (!this.documentStore.hasDoc(docRef)) return;
|
|
|
|
this.documentStore.removeDoc(docRef);
|
|
|
|
this._fields.forEach(function (field) {
|
|
const fieldTokens = this.pipeline.run(elasticlunr.tokenizer(doc[field]));
|
|
fieldTokens.forEach(function (token) {
|
|
this.index[field].removeToken(token, docRef);
|
|
}, this);
|
|
}, this);
|
|
|
|
if (emitEvent) this.eventEmitter.emit('remove', doc, this);
|
|
};
|
|
|
|
/**
|
|
* Updates a document in the index.
|
|
*
|
|
* When a document contained within the index gets updated, fields changed,
|
|
* added or removed, to make sure it correctly matched against search queries,
|
|
* it should be updated in the index.
|
|
*
|
|
* This method is just a wrapper around `remove` and `add`
|
|
*
|
|
* An 'update' event is emitted with the document that has been updated and the index.
|
|
* This event can be silenced by passing false as the second argument to update. Only
|
|
* an update event will be fired, the 'add' and 'remove' events of the underlying calls
|
|
* are silenced.
|
|
*
|
|
* @param {Object} doc The document to update in the index.
|
|
* @param {Boolean} emitEvent Whether to emit update events, defaults to true
|
|
* @see Index.prototype.remove
|
|
* @see Index.prototype.add
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.updateDoc = function (doc, emitEvent) {
|
|
var emitEvent = emitEvent === undefined ? true : emitEvent;
|
|
|
|
this.removeDocByRef(doc[this._ref], false);
|
|
this.addDoc(doc, false);
|
|
|
|
if (emitEvent) this.eventEmitter.emit('update', doc, this);
|
|
};
|
|
|
|
/**
|
|
* Calculates the inverse document frequency for a token within the index of a field.
|
|
*
|
|
* @param {String} token The token to calculate the idf of.
|
|
* @param {String} field The field to compute idf.
|
|
* @see Index.prototype.idf
|
|
* @private
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.idf = function (term, field) {
|
|
const cacheKey = '@' + field + '/' + term;
|
|
if (Object.prototype.hasOwnProperty.call(this._idfCache, cacheKey))
|
|
return this._idfCache[cacheKey];
|
|
|
|
const df = this.index[field].getDocFreq(term);
|
|
const idf = 1 + Math.log(this.documentStore.length / (df + 1));
|
|
this._idfCache[cacheKey] = idf;
|
|
|
|
return idf;
|
|
};
|
|
|
|
/**
|
|
* get fields of current index instance
|
|
*
|
|
* @return {Array}
|
|
*/
|
|
elasticlunr.Index.prototype.getFields = function () {
|
|
return this._fields.slice();
|
|
};
|
|
|
|
/**
|
|
* Searches the index using the passed query.
|
|
* Queries should be a string, multiple words are allowed.
|
|
*
|
|
* If config is null, will search all fields defaultly, and lead to OR based query.
|
|
* If config is specified, will search specified with query time boosting.
|
|
*
|
|
* All query tokens are passed through the same pipeline that document tokens
|
|
* are passed through, so any language processing involved will be run on every
|
|
* query term.
|
|
*
|
|
* Each query term is expanded, so that the term 'he' might be expanded to
|
|
* 'hello' and 'help' if those terms were already included in the index.
|
|
*
|
|
* Matching documents are returned as an array of objects, each object contains
|
|
* the matching document ref, as set for this index, and the similarity score
|
|
* for this document against the query.
|
|
*
|
|
* @param {String} query The query to search the index with.
|
|
* @param {JSON} userConfig The user query config, JSON format.
|
|
* @return {Object}
|
|
* @see Index.prototype.idf
|
|
* @see Index.prototype.documentVector
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.search = function (query, userConfig) {
|
|
if (!query) return [];
|
|
if (typeof query === 'string') {
|
|
query = { any: query };
|
|
} else {
|
|
query = JSON.parse(JSON.stringify(query));
|
|
}
|
|
|
|
let configStr = null;
|
|
if (userConfig != null) {
|
|
configStr = JSON.stringify(userConfig);
|
|
}
|
|
|
|
const config = new elasticlunr.Configuration(configStr, this.getFields()).get();
|
|
|
|
const queryTokens = {};
|
|
const queryFields = Object.keys(query);
|
|
|
|
for (let i = 0; i < queryFields.length; i++) {
|
|
const key = queryFields[i];
|
|
|
|
queryTokens[key] = this.pipeline.run(elasticlunr.tokenizer(query[key]));
|
|
}
|
|
|
|
const queryResults = {};
|
|
|
|
for (const field in config) {
|
|
const tokens = queryTokens[field] || queryTokens.any;
|
|
if (!tokens) {
|
|
continue;
|
|
}
|
|
|
|
const fieldSearchResults = this.fieldSearch(tokens, field, config);
|
|
const fieldBoost = config[field].boost;
|
|
|
|
for (var docRef in fieldSearchResults) {
|
|
fieldSearchResults[docRef] = fieldSearchResults[docRef] * fieldBoost;
|
|
}
|
|
|
|
for (var docRef in fieldSearchResults) {
|
|
if (docRef in queryResults) {
|
|
queryResults[docRef] += fieldSearchResults[docRef];
|
|
} else {
|
|
queryResults[docRef] = fieldSearchResults[docRef];
|
|
}
|
|
}
|
|
}
|
|
|
|
const results = [];
|
|
let result;
|
|
for (var docRef in queryResults) {
|
|
result = { ref: docRef, score: queryResults[docRef] };
|
|
if (this.documentStore.hasDoc(docRef)) {
|
|
result.doc = this.documentStore.getDoc(docRef);
|
|
}
|
|
results.push(result);
|
|
}
|
|
|
|
results.sort(function (a, b) {
|
|
return b.score - a.score;
|
|
});
|
|
return results;
|
|
};
|
|
|
|
/**
|
|
* search queryTokens in specified field.
|
|
*
|
|
* @param {Array} queryTokens The query tokens to query in this field.
|
|
* @param {String} field Field to query in.
|
|
* @param {elasticlunr.Configuration} config The user query config, JSON format.
|
|
* @return {Object}
|
|
*/
|
|
elasticlunr.Index.prototype.fieldSearch = function (
|
|
queryTokens,
|
|
fieldName,
|
|
config
|
|
) {
|
|
const booleanType = config[fieldName].bool;
|
|
const expand = config[fieldName].expand;
|
|
const boost = config[fieldName].boost;
|
|
let scores = null;
|
|
const docTokens = {};
|
|
|
|
// Do nothing if the boost is 0
|
|
if (boost === 0) {
|
|
return;
|
|
}
|
|
|
|
queryTokens.forEach(function (token) {
|
|
let tokens = [token];
|
|
if (expand === true) {
|
|
tokens = this.index[fieldName].expandToken(token);
|
|
}
|
|
// Consider every query token in turn. If expanded, each query token
|
|
// corresponds to a set of tokens, which is all tokens in the
|
|
// index matching the pattern queryToken* .
|
|
// For the set of tokens corresponding to a query token, find and score
|
|
// all matching documents. Store those scores in queryTokenScores,
|
|
// keyed by docRef.
|
|
// Then, depending on the value of booleanType, combine the scores
|
|
// for this query token with previous scores. If booleanType is OR,
|
|
// then merge the scores by summing into the accumulated total, adding
|
|
// new document scores are required (effectively a union operator).
|
|
// If booleanType is AND, accumulate scores only if the document
|
|
// has previously been scored by another query token (an intersection
|
|
// operation0.
|
|
// Furthermore, since when booleanType is AND, additional
|
|
// query tokens can't add new documents to the result set, use the
|
|
// current document set to limit the processing of each new query
|
|
// token for efficiency (i.e., incremental intersection).
|
|
|
|
const queryTokenScores = {};
|
|
tokens.forEach(function (key) {
|
|
let docs = this.index[fieldName].getDocs(key);
|
|
const idf = this.idf(key, fieldName);
|
|
|
|
if (scores && booleanType === 'AND') {
|
|
// special case, we can rule out documents that have been
|
|
// already been filtered out because they weren't scored
|
|
// by previous query token passes.
|
|
const filteredDocs = {};
|
|
for (var docRef in scores) {
|
|
if (docRef in docs) {
|
|
filteredDocs[docRef] = docs[docRef];
|
|
}
|
|
}
|
|
docs = filteredDocs;
|
|
}
|
|
// only record appeared token for retrieved documents for the
|
|
// original token, not for expaned token.
|
|
// beause for doing coordNorm for a retrieved document, coordNorm only care how many
|
|
// query token appear in that document.
|
|
// so expanded token should not be added into docTokens, if added, this will pollute the
|
|
// coordNorm
|
|
if (key === token) {
|
|
this.fieldSearchStats(docTokens, key, docs);
|
|
}
|
|
|
|
for (var docRef in docs) {
|
|
const tf = this.index[fieldName].getTermFrequency(key, docRef);
|
|
const fieldLength = this.documentStore.getFieldLength(
|
|
docRef,
|
|
fieldName
|
|
);
|
|
let fieldLengthNorm = 1;
|
|
if (fieldLength !== 0) {
|
|
fieldLengthNorm = 1 / Math.sqrt(fieldLength);
|
|
}
|
|
|
|
let penality = 1;
|
|
if (key !== token) {
|
|
// currently I'm not sure if this penality is enough,
|
|
// need to do verification
|
|
penality =
|
|
(1 - (key.length - token.length) / key.length) * 0.15;
|
|
}
|
|
|
|
const score = tf * idf * fieldLengthNorm * penality;
|
|
|
|
if (docRef in queryTokenScores) {
|
|
queryTokenScores[docRef] += score;
|
|
} else {
|
|
queryTokenScores[docRef] = score;
|
|
}
|
|
}
|
|
}, this);
|
|
|
|
scores = this.mergeScores(scores, queryTokenScores, booleanType);
|
|
}, this);
|
|
|
|
scores = this.coordNorm(scores, docTokens, queryTokens.length);
|
|
return scores;
|
|
};
|
|
|
|
/**
|
|
* Merge the scores from one set of tokens into an accumulated score table.
|
|
* Exact operation depends on the op parameter. If op is 'AND', then only the
|
|
* intersection of the two score lists is retained. Otherwise, the union of
|
|
* the two score lists is returned. For internal use only.
|
|
*
|
|
* @param {Object} bool accumulated scores. Should be null on first call.
|
|
* @param {String} scores new scores to merge into accumScores.
|
|
* @param {Object} op merge operation (should be 'AND' or 'OR').
|
|
*
|
|
*/
|
|
|
|
elasticlunr.Index.prototype.mergeScores = function (accumScores, scores, op) {
|
|
if (!accumScores) {
|
|
return scores;
|
|
}
|
|
if (op === 'AND') {
|
|
const intersection = {};
|
|
for (var docRef in scores) {
|
|
if (docRef in accumScores) {
|
|
intersection[docRef] = accumScores[docRef] + scores[docRef];
|
|
}
|
|
}
|
|
return intersection;
|
|
} else {
|
|
for (var docRef in scores) {
|
|
if (docRef in accumScores) {
|
|
accumScores[docRef] += scores[docRef];
|
|
} else {
|
|
accumScores[docRef] = scores[docRef];
|
|
}
|
|
}
|
|
return accumScores;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Record the occuring query token of retrieved doc specified by doc field.
|
|
* Only for inner user.
|
|
*
|
|
* @param {Object} docTokens a data structure stores which token appears in the retrieved doc.
|
|
* @param {String} token query token
|
|
* @param {Object} docs the retrieved documents of the query token
|
|
*
|
|
*/
|
|
elasticlunr.Index.prototype.fieldSearchStats = function (docTokens, token, docs) {
|
|
for (const doc in docs) {
|
|
if (doc in docTokens) {
|
|
docTokens[doc].push(token);
|
|
} else {
|
|
docTokens[doc] = [token];
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* coord norm the score of a doc.
|
|
* if a doc contain more query tokens, then the score will larger than the doc
|
|
* contains less query tokens.
|
|
*
|
|
* only for inner use.
|
|
*
|
|
* @param {Object} results first results
|
|
* @param {Object} docs field search results of a token
|
|
* @param {Integer} n query token number
|
|
* @return {Object}
|
|
*/
|
|
elasticlunr.Index.prototype.coordNorm = function (scores, docTokens, n) {
|
|
for (const doc in scores) {
|
|
if (!(doc in docTokens)) continue;
|
|
const tokens = docTokens[doc].length;
|
|
scores[doc] = (scores[doc] * tokens) / n;
|
|
}
|
|
|
|
return scores;
|
|
};
|
|
|
|
/**
|
|
* Returns a representation of the index ready for serialisation.
|
|
*
|
|
* @return {Object}
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.toJSON = function () {
|
|
const indexJson = {};
|
|
this._fields.forEach(function (field) {
|
|
indexJson[field] = this.index[field].toJSON();
|
|
}, this);
|
|
|
|
return {
|
|
version: elasticlunr.version,
|
|
fields: this._fields,
|
|
ref: this._ref,
|
|
documentStore: this.documentStore.toJSON(),
|
|
index: indexJson,
|
|
pipeline: this.pipeline.toJSON(),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Applies a plugin to the current index.
|
|
*
|
|
* A plugin is a function that is called with the index as its context.
|
|
* Plugins can be used to customise or extend the behaviour the index
|
|
* in some way. A plugin is just a function, that encapsulated the custom
|
|
* behaviour that should be applied to the index.
|
|
*
|
|
* The plugin function will be called with the index as its argument, additional
|
|
* arguments can also be passed when calling use. The function will be called
|
|
* with the index as its context.
|
|
*
|
|
* Example:
|
|
*
|
|
* var myPlugin = function (idx, arg1, arg2) {
|
|
* // `this` is the index to be extended
|
|
* // apply any extensions etc here.
|
|
* }
|
|
*
|
|
* var idx = elasticlunr(function () {
|
|
* this.use(myPlugin, 'arg1', 'arg2')
|
|
* })
|
|
*
|
|
* @param {Function} plugin The plugin to apply.
|
|
* @memberOf Index
|
|
*/
|
|
elasticlunr.Index.prototype.use = function (plugin) {
|
|
const args = Array.prototype.slice.call(arguments, 1);
|
|
args.unshift(this);
|
|
plugin.apply(this, args);
|
|
};
|
|
/*!
|
|
* elasticlunr.DocumentStore
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.DocumentStore is a simple key-value document store used for storing sets of tokens for
|
|
* documents stored in index.
|
|
*
|
|
* elasticlunr.DocumentStore store original JSON format documents that you could build search snippet by this original JSON document.
|
|
*
|
|
* user could choose whether original JSON format document should be store, if no configuration then document will be stored defaultly.
|
|
* If user care more about the index size, user could select not store JSON documents, then this will has some defects, such as user
|
|
* could not use JSON document to generate snippets of search results.
|
|
*
|
|
* @param {Boolean} save If the original JSON document should be stored.
|
|
* @constructor
|
|
* @module
|
|
*/
|
|
elasticlunr.DocumentStore = function (save) {
|
|
if (save === null || save === undefined) {
|
|
this._save = true;
|
|
} else {
|
|
this._save = save;
|
|
}
|
|
|
|
this.docs = {};
|
|
this.docInfo = {};
|
|
this.length = 0;
|
|
};
|
|
|
|
/**
|
|
* Loads a previously serialised document store
|
|
*
|
|
* @param {Object} serialisedData The serialised document store to load.
|
|
* @return {elasticlunr.DocumentStore}
|
|
*/
|
|
elasticlunr.DocumentStore.load = function (serialisedData) {
|
|
const store = new this();
|
|
|
|
store.length = serialisedData.length;
|
|
store.docs = serialisedData.docs;
|
|
store.docInfo = serialisedData.docInfo;
|
|
store._save = serialisedData.save;
|
|
|
|
return store;
|
|
};
|
|
|
|
/**
|
|
* check if current instance store the original doc
|
|
*
|
|
* @return {Boolean}
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.isDocStored = function () {
|
|
return this._save;
|
|
};
|
|
|
|
/**
|
|
* Stores the given doc in the document store against the given id.
|
|
* If docRef already exist, then update doc.
|
|
*
|
|
* Document is store by original JSON format, then you could use original document to generate search snippets.
|
|
*
|
|
* @param {Integer|String} docRef The key used to store the JSON format doc.
|
|
* @param {Object} doc The JSON format doc.
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.addDoc = function (docRef, doc) {
|
|
if (!this.hasDoc(docRef)) this.length++;
|
|
|
|
if (this._save === true) {
|
|
this.docs[docRef] = clone(doc);
|
|
} else {
|
|
this.docs[docRef] = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Retrieves the JSON doc from the document store for a given key.
|
|
*
|
|
* If docRef not found, return null.
|
|
* If user set not storing the documents, return null.
|
|
*
|
|
* @param {Integer|String} docRef The key to lookup and retrieve from the document store.
|
|
* @return {Object}
|
|
* @memberOf DocumentStore
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.getDoc = function (docRef) {
|
|
if (this.hasDoc(docRef) === false) return null;
|
|
return this.docs[docRef];
|
|
};
|
|
|
|
/**
|
|
* Checks whether the document store contains a key (docRef).
|
|
*
|
|
* @param {Integer|String} docRef The id to look up in the document store.
|
|
* @return {Boolean}
|
|
* @memberOf DocumentStore
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.hasDoc = function (docRef) {
|
|
return docRef in this.docs;
|
|
};
|
|
|
|
/**
|
|
* Removes the value for a key in the document store.
|
|
*
|
|
* @param {Integer|String} docRef The id to remove from the document store.
|
|
* @memberOf DocumentStore
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.removeDoc = function (docRef) {
|
|
if (!this.hasDoc(docRef)) return;
|
|
|
|
delete this.docs[docRef];
|
|
delete this.docInfo[docRef];
|
|
this.length--;
|
|
};
|
|
|
|
/**
|
|
* Add field length of a document's field tokens from pipeline results.
|
|
* The field length of a document is used to do field length normalization even without the original JSON document stored.
|
|
*
|
|
* @param {Integer|String} docRef document's id or reference
|
|
* @param {String} fieldName field name
|
|
* @param {Integer} length field length
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.addFieldLength = function (
|
|
docRef,
|
|
fieldName,
|
|
length
|
|
) {
|
|
if (docRef === null || docRef === undefined) return;
|
|
if (this.hasDoc(docRef) == false) return;
|
|
|
|
if (!this.docInfo[docRef]) this.docInfo[docRef] = {};
|
|
this.docInfo[docRef][fieldName] = length;
|
|
};
|
|
|
|
/**
|
|
* Update field length of a document's field tokens from pipeline results.
|
|
* The field length of a document is used to do field length normalization even without the original JSON document stored.
|
|
*
|
|
* @param {Integer|String} docRef document's id or reference
|
|
* @param {String} fieldName field name
|
|
* @param {Integer} length field length
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.updateFieldLength = function (
|
|
docRef,
|
|
fieldName,
|
|
length
|
|
) {
|
|
if (docRef === null || docRef === undefined) return;
|
|
if (this.hasDoc(docRef) == false) return;
|
|
|
|
this.addFieldLength(docRef, fieldName, length);
|
|
};
|
|
|
|
/**
|
|
* get field length of a document by docRef
|
|
*
|
|
* @param {Integer|String} docRef document id or reference
|
|
* @param {String} fieldName field name
|
|
* @return {Integer} field length
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.getFieldLength = function (docRef, fieldName) {
|
|
if (docRef === null || docRef === undefined) return 0;
|
|
|
|
if (!(docRef in this.docs)) return 0;
|
|
if (!(fieldName in this.docInfo[docRef])) return 0;
|
|
return this.docInfo[docRef][fieldName];
|
|
};
|
|
|
|
/**
|
|
* Returns a JSON representation of the document store used for serialisation.
|
|
*
|
|
* @return {Object} JSON format
|
|
* @memberOf DocumentStore
|
|
*/
|
|
elasticlunr.DocumentStore.prototype.toJSON = function () {
|
|
return {
|
|
docs: this.docs,
|
|
docInfo: this.docInfo,
|
|
length: this.length,
|
|
save: this._save,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Cloning object
|
|
*
|
|
* @param {Object} object in JSON format
|
|
* @return {Object} copied object
|
|
*/
|
|
function clone(obj) {
|
|
if (obj === null || typeof obj !== 'object') return obj;
|
|
|
|
const copy = obj.constructor();
|
|
|
|
for (const attr in obj) {
|
|
if (obj.hasOwnProperty(attr)) copy[attr] = obj[attr];
|
|
}
|
|
|
|
return copy;
|
|
}
|
|
/*!
|
|
* elasticlunr.stemmer
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
* Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.stemmer is an english language stemmer, this is a JavaScript
|
|
* implementation of the PorterStemmer taken from http://tartarus.org/~martin
|
|
*
|
|
* @module
|
|
* @param {String} str The string to stem
|
|
* @return {String}
|
|
* @see elasticlunr.Pipeline
|
|
*/
|
|
elasticlunr.stemmer = (function () {
|
|
const step2list = {
|
|
ational: 'ate',
|
|
tional: 'tion',
|
|
enci: 'ence',
|
|
anci: 'ance',
|
|
izer: 'ize',
|
|
bli: 'ble',
|
|
alli: 'al',
|
|
entli: 'ent',
|
|
eli: 'e',
|
|
ousli: 'ous',
|
|
ization: 'ize',
|
|
ation: 'ate',
|
|
ator: 'ate',
|
|
alism: 'al',
|
|
iveness: 'ive',
|
|
fulness: 'ful',
|
|
ousness: 'ous',
|
|
aliti: 'al',
|
|
iviti: 'ive',
|
|
biliti: 'ble',
|
|
logi: 'log',
|
|
};
|
|
|
|
const step3list = {
|
|
icate: 'ic',
|
|
ative: '',
|
|
alize: 'al',
|
|
iciti: 'ic',
|
|
ical: 'ic',
|
|
ful: '',
|
|
ness: '',
|
|
};
|
|
|
|
const c = '[^aeiou]'; // consonant
|
|
const v = '[aeiouy]'; // vowel
|
|
const C = c + '[^aeiouy]*'; // consonant sequence
|
|
const V = v + '[aeiou]*'; // vowel sequence
|
|
|
|
const mgr0 = '^(' + C + ')?' + V + C; // [C]VC... is m>0
|
|
const meq1 = '^(' + C + ')?' + V + C + '(' + V + ')?$'; // [C]VC[V] is m=1
|
|
const mgr1 = '^(' + C + ')?' + V + C + V + C; // [C]VCVC... is m>1
|
|
const s_v = '^(' + C + ')?' + v; // vowel in stem
|
|
|
|
const re_mgr0 = new RegExp(mgr0);
|
|
const re_mgr1 = new RegExp(mgr1);
|
|
const re_meq1 = new RegExp(meq1);
|
|
const re_s_v = new RegExp(s_v);
|
|
|
|
const re_1a = /^(.+?)(ss|i)es$/;
|
|
const re2_1a = /^(.+?)([^s])s$/;
|
|
const re_1b = /^(.+?)eed$/;
|
|
const re2_1b = /^(.+?)(ed|ing)$/;
|
|
const re_1b_2 = /.$/;
|
|
const re2_1b_2 = /(at|bl|iz)$/;
|
|
const re3_1b_2 = new RegExp('([^aeiouylsz])\\1$');
|
|
const re4_1b_2 = new RegExp('^' + C + v + '[^aeiouwxy]$');
|
|
|
|
const re_1c = /^(.+?[^aeiou])y$/;
|
|
const re_2 =
|
|
/^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/;
|
|
|
|
const re_3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/;
|
|
|
|
const re_4 =
|
|
/^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/;
|
|
const re2_4 = /^(.+?)(s|t)(ion)$/;
|
|
|
|
const re_5 = /^(.+?)e$/;
|
|
const re_5_1 = /ll$/;
|
|
const re3_5 = new RegExp('^' + C + v + '[^aeiouwxy]$');
|
|
|
|
const porterStemmer = function porterStemmer(w) {
|
|
let stem, suffix, firstch, re, re2, re3, re4;
|
|
|
|
if (w.length < 3) {
|
|
return w;
|
|
}
|
|
|
|
firstch = w.substr(0, 1);
|
|
if (firstch == 'y') {
|
|
w = firstch.toUpperCase() + w.substr(1);
|
|
}
|
|
|
|
// Step 1a
|
|
re = re_1a;
|
|
re2 = re2_1a;
|
|
|
|
if (re.test(w)) {
|
|
w = w.replace(re, '$1$2');
|
|
} else if (re2.test(w)) {
|
|
w = w.replace(re2, '$1$2');
|
|
}
|
|
|
|
// Step 1b
|
|
re = re_1b;
|
|
re2 = re2_1b;
|
|
if (re.test(w)) {
|
|
var fp = re.exec(w);
|
|
re = re_mgr0;
|
|
if (re.test(fp[1])) {
|
|
re = re_1b_2;
|
|
w = w.replace(re, '');
|
|
}
|
|
} else if (re2.test(w)) {
|
|
var fp = re2.exec(w);
|
|
stem = fp[1];
|
|
re2 = re_s_v;
|
|
if (re2.test(stem)) {
|
|
w = stem;
|
|
re2 = re2_1b_2;
|
|
re3 = re3_1b_2;
|
|
re4 = re4_1b_2;
|
|
if (re2.test(w)) {
|
|
w = w + 'e';
|
|
} else if (re3.test(w)) {
|
|
re = re_1b_2;
|
|
w = w.replace(re, '');
|
|
} else if (re4.test(w)) {
|
|
w = w + 'e';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 1c - replace suffix y or Y by i if preceded by a non-vowel which is not the first letter of the word (so cry -> cri, by -> by, say -> say)
|
|
re = re_1c;
|
|
if (re.test(w)) {
|
|
var fp = re.exec(w);
|
|
stem = fp[1];
|
|
w = stem + 'i';
|
|
}
|
|
|
|
// Step 2
|
|
re = re_2;
|
|
if (re.test(w)) {
|
|
var fp = re.exec(w);
|
|
stem = fp[1];
|
|
suffix = fp[2];
|
|
re = re_mgr0;
|
|
if (re.test(stem)) {
|
|
w = stem + step2list[suffix];
|
|
}
|
|
}
|
|
|
|
// Step 3
|
|
re = re_3;
|
|
if (re.test(w)) {
|
|
var fp = re.exec(w);
|
|
stem = fp[1];
|
|
suffix = fp[2];
|
|
re = re_mgr0;
|
|
if (re.test(stem)) {
|
|
w = stem + step3list[suffix];
|
|
}
|
|
}
|
|
|
|
// Step 4
|
|
re = re_4;
|
|
re2 = re2_4;
|
|
if (re.test(w)) {
|
|
var fp = re.exec(w);
|
|
stem = fp[1];
|
|
re = re_mgr1;
|
|
if (re.test(stem)) {
|
|
w = stem;
|
|
}
|
|
} else if (re2.test(w)) {
|
|
var fp = re2.exec(w);
|
|
stem = fp[1] + fp[2];
|
|
re2 = re_mgr1;
|
|
if (re2.test(stem)) {
|
|
w = stem;
|
|
}
|
|
}
|
|
|
|
// Step 5
|
|
re = re_5;
|
|
if (re.test(w)) {
|
|
var fp = re.exec(w);
|
|
stem = fp[1];
|
|
re = re_mgr1;
|
|
re2 = re_meq1;
|
|
re3 = re3_5;
|
|
if (re.test(stem) || (re2.test(stem) && !re3.test(stem))) {
|
|
w = stem;
|
|
}
|
|
}
|
|
|
|
re = re_5_1;
|
|
re2 = re_mgr1;
|
|
if (re.test(w) && re2.test(w)) {
|
|
re = re_1b_2;
|
|
w = w.replace(re, '');
|
|
}
|
|
|
|
// and turn initial Y back to y
|
|
|
|
if (firstch == 'y') {
|
|
w = firstch.toLowerCase() + w.substr(1);
|
|
}
|
|
|
|
return w;
|
|
};
|
|
|
|
return porterStemmer;
|
|
})();
|
|
|
|
elasticlunr.Pipeline.registerFunction(elasticlunr.stemmer, 'stemmer');
|
|
/*!
|
|
* elasticlunr.stopWordFilter
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.stopWordFilter is an English language stop words filter, any words
|
|
* contained in the stop word list will not be passed through the filter.
|
|
*
|
|
* This is intended to be used in the Pipeline. If the token does not pass the
|
|
* filter then undefined will be returned.
|
|
* Currently this StopwordFilter using dictionary to do O(1) time complexity stop word filtering.
|
|
*
|
|
* @module
|
|
* @param {String} token The token to pass through the filter
|
|
* @return {String}
|
|
* @see elasticlunr.Pipeline
|
|
*/
|
|
elasticlunr.stopWordFilter = function (token) {
|
|
if (token && elasticlunr.stopWordFilter.stopWords[token] !== true) {
|
|
return token;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Remove predefined stop words
|
|
* if user want to use customized stop words, user could use this function to delete
|
|
* all predefined stopwords.
|
|
*
|
|
* @return {null}
|
|
*/
|
|
elasticlunr.clearStopWords = function () {
|
|
elasticlunr.stopWordFilter.stopWords = {};
|
|
};
|
|
|
|
/**
|
|
* Add customized stop words
|
|
* user could use this function to add customized stop words
|
|
*
|
|
* @params {Array} words customized stop words
|
|
* @return {null}
|
|
*/
|
|
elasticlunr.addStopWords = function (words) {
|
|
if (words == null || Array.isArray(words) === false) return;
|
|
|
|
words.forEach(function (word) {
|
|
elasticlunr.stopWordFilter.stopWords[word] = true;
|
|
}, this);
|
|
};
|
|
|
|
/**
|
|
* Reset to default stop words
|
|
* user could use this function to restore default stop words
|
|
*
|
|
* @return {null}
|
|
*/
|
|
elasticlunr.resetStopWords = function () {
|
|
elasticlunr.stopWordFilter.stopWords = elasticlunr.defaultStopWords;
|
|
};
|
|
|
|
elasticlunr.defaultStopWords = {
|
|
'': true,
|
|
a: true,
|
|
able: true,
|
|
about: true,
|
|
across: true,
|
|
after: true,
|
|
all: true,
|
|
almost: true,
|
|
also: true,
|
|
am: true,
|
|
among: true,
|
|
an: true,
|
|
and: true,
|
|
any: true,
|
|
are: true,
|
|
as: true,
|
|
at: true,
|
|
be: true,
|
|
because: true,
|
|
been: true,
|
|
but: true,
|
|
by: true,
|
|
can: true,
|
|
cannot: true,
|
|
could: true,
|
|
dear: true,
|
|
did: true,
|
|
do: true,
|
|
does: true,
|
|
either: true,
|
|
else: true,
|
|
ever: true,
|
|
every: true,
|
|
for: true,
|
|
from: true,
|
|
get: true,
|
|
got: true,
|
|
had: true,
|
|
has: true,
|
|
have: true,
|
|
he: true,
|
|
her: true,
|
|
hers: true,
|
|
him: true,
|
|
his: true,
|
|
how: true,
|
|
however: true,
|
|
i: true,
|
|
if: true,
|
|
in: true,
|
|
into: true,
|
|
is: true,
|
|
it: true,
|
|
its: true,
|
|
just: true,
|
|
least: true,
|
|
let: true,
|
|
like: true,
|
|
likely: true,
|
|
may: true,
|
|
me: true,
|
|
might: true,
|
|
most: true,
|
|
must: true,
|
|
my: true,
|
|
neither: true,
|
|
no: true,
|
|
nor: true,
|
|
not: true,
|
|
of: true,
|
|
off: true,
|
|
often: true,
|
|
on: true,
|
|
only: true,
|
|
or: true,
|
|
other: true,
|
|
our: true,
|
|
own: true,
|
|
rather: true,
|
|
said: true,
|
|
say: true,
|
|
says: true,
|
|
she: true,
|
|
should: true,
|
|
since: true,
|
|
so: true,
|
|
some: true,
|
|
than: true,
|
|
that: true,
|
|
the: true,
|
|
their: true,
|
|
them: true,
|
|
then: true,
|
|
there: true,
|
|
these: true,
|
|
they: true,
|
|
this: true,
|
|
tis: true,
|
|
to: true,
|
|
too: true,
|
|
twas: true,
|
|
us: true,
|
|
wants: true,
|
|
was: true,
|
|
we: true,
|
|
were: true,
|
|
what: true,
|
|
when: true,
|
|
where: true,
|
|
which: true,
|
|
while: true,
|
|
who: true,
|
|
whom: true,
|
|
why: true,
|
|
will: true,
|
|
with: true,
|
|
would: true,
|
|
yet: true,
|
|
you: true,
|
|
your: true,
|
|
};
|
|
|
|
elasticlunr.stopWordFilter.stopWords = elasticlunr.defaultStopWords;
|
|
|
|
elasticlunr.Pipeline.registerFunction(elasticlunr.stopWordFilter, 'stopWordFilter');
|
|
/*!
|
|
* elasticlunr.trimmer
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.trimmer is a pipeline function for trimming non word
|
|
* characters from the begining and end of tokens before they
|
|
* enter the index.
|
|
*
|
|
* This implementation may not work correctly for non latin
|
|
* characters and should either be removed or adapted for use
|
|
* with languages with non-latin characters.
|
|
*
|
|
* @module
|
|
* @param {String} token The token to pass through the filter
|
|
* @return {String}
|
|
* @see elasticlunr.Pipeline
|
|
*/
|
|
elasticlunr.trimmer = function (token) {
|
|
if (token === null || token === undefined) {
|
|
throw new Error('token should not be undefined');
|
|
}
|
|
|
|
return token.replace(/^\W+/, '').replace(/\W+$/, '');
|
|
};
|
|
|
|
elasticlunr.Pipeline.registerFunction(elasticlunr.trimmer, 'trimmer');
|
|
/*!
|
|
* elasticlunr.InvertedIndex
|
|
* Copyright (C) 2017 Wei Song
|
|
* Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.InvertedIndex is used for efficiently storing and
|
|
* lookup of documents that contain a given token.
|
|
*
|
|
* @constructor
|
|
*/
|
|
elasticlunr.InvertedIndex = function () {
|
|
this.root = { docs: {}, df: 0 };
|
|
};
|
|
|
|
/**
|
|
* Loads a previously serialised inverted index.
|
|
*
|
|
* @param {Object} serialisedData The serialised inverted index to load.
|
|
* @return {elasticlunr.InvertedIndex}
|
|
*/
|
|
elasticlunr.InvertedIndex.load = function (serialisedData) {
|
|
const idx = new this();
|
|
idx.root = serialisedData.root;
|
|
|
|
return idx;
|
|
};
|
|
|
|
/**
|
|
* Adds a {token: tokenInfo} pair to the inverted index.
|
|
* If the token already exist, then update the tokenInfo.
|
|
*
|
|
* tokenInfo format: { ref: 1, tf: 2}
|
|
* tokenInfor should contains the document's ref and the tf(token frequency) of that token in
|
|
* the document.
|
|
*
|
|
* By default this function starts at the root of the current inverted index, however
|
|
* it can start at any node of the inverted index if required.
|
|
*
|
|
* @param {String} token
|
|
* @param {Object} tokenInfo format: { ref: 1, tf: 2}
|
|
* @param {Object} root An optional node at which to start looking for the
|
|
* correct place to enter the doc, by default the root of this elasticlunr.InvertedIndex
|
|
* is used.
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.addToken = function (token, tokenInfo, root) {
|
|
var root = root || this.root;
|
|
let idx = 0;
|
|
|
|
while (idx <= token.length - 1) {
|
|
const key = token[idx];
|
|
|
|
if (!(key in root)) root[key] = { docs: {}, df: 0 };
|
|
idx += 1;
|
|
root = root[key];
|
|
}
|
|
|
|
const docRef = tokenInfo.ref;
|
|
if (!root.docs[docRef]) {
|
|
// if this doc not exist, then add this doc
|
|
root.docs[docRef] = { tf: tokenInfo.tf };
|
|
root.df += 1;
|
|
} else {
|
|
// if this doc already exist, then update tokenInfo
|
|
root.docs[docRef] = { tf: tokenInfo.tf };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Checks whether a token is in this elasticlunr.InvertedIndex.
|
|
*
|
|
*
|
|
* @param {String} token The token to be checked
|
|
* @return {Boolean}
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.hasToken = function (token) {
|
|
if (!token) return false;
|
|
|
|
let node = this.root;
|
|
|
|
for (let i = 0; i < token.length; i++) {
|
|
if (!node[token[i]]) return false;
|
|
node = node[token[i]];
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Retrieve a node from the inverted index for a given token.
|
|
* If token not found in this InvertedIndex, return null.
|
|
*
|
|
*
|
|
* @param {String} token The token to get the node for.
|
|
* @return {Object}
|
|
* @see InvertedIndex.prototype.get
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.getNode = function (token) {
|
|
if (!token) return null;
|
|
|
|
let node = this.root;
|
|
|
|
for (let i = 0; i < token.length; i++) {
|
|
if (!node[token[i]]) return null;
|
|
node = node[token[i]];
|
|
}
|
|
|
|
return node;
|
|
};
|
|
|
|
/**
|
|
* Retrieve the documents of a given token.
|
|
* If token not found, return {}.
|
|
*
|
|
*
|
|
* @param {String} token The token to get the documents for.
|
|
* @return {Object}
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.getDocs = function (token) {
|
|
const node = this.getNode(token);
|
|
if (node == null) {
|
|
return {};
|
|
}
|
|
|
|
return node.docs;
|
|
};
|
|
|
|
/**
|
|
* Retrieve term frequency of given token in given docRef.
|
|
* If token or docRef not found, return 0.
|
|
*
|
|
*
|
|
* @param {String} token The token to get the documents for.
|
|
* @param {String|Integer} docRef
|
|
* @return {Integer}
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.getTermFrequency = function (token, docRef) {
|
|
const node = this.getNode(token);
|
|
|
|
if (node == null) {
|
|
return 0;
|
|
}
|
|
|
|
if (!(docRef in node.docs)) {
|
|
return 0;
|
|
}
|
|
|
|
return node.docs[docRef].tf;
|
|
};
|
|
|
|
/**
|
|
* Retrieve the document frequency of given token.
|
|
* If token not found, return 0.
|
|
*
|
|
*
|
|
* @param {String} token The token to get the documents for.
|
|
* @return {Object}
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.getDocFreq = function (token) {
|
|
const node = this.getNode(token);
|
|
|
|
if (node == null) {
|
|
return 0;
|
|
}
|
|
|
|
return node.df;
|
|
};
|
|
|
|
/**
|
|
* Remove the document identified by document's ref from the token in the inverted index.
|
|
*
|
|
*
|
|
* @param {String} token Remove the document from which token.
|
|
* @param {String} ref The ref of the document to remove from given token.
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.removeToken = function (token, ref) {
|
|
if (!token) return;
|
|
const node = this.getNode(token);
|
|
|
|
if (node == null) return;
|
|
|
|
if (ref in node.docs) {
|
|
delete node.docs[ref];
|
|
node.df -= 1;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Find all the possible suffixes of given token using tokens currently in the inverted index.
|
|
* If token not found, return empty Array.
|
|
*
|
|
* @param {String} token The token to expand.
|
|
* @return {Array}
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.expandToken = function (token, memo, root) {
|
|
if (token == null || token == '') return [];
|
|
var memo = memo || [];
|
|
|
|
if (root == void 0) {
|
|
root = this.getNode(token);
|
|
if (root == null) return memo;
|
|
}
|
|
|
|
if (root.df > 0) memo.push(token);
|
|
|
|
for (const key in root) {
|
|
if (key === 'docs') continue;
|
|
if (key === 'df') continue;
|
|
this.expandToken(token + key, memo, root[key]);
|
|
}
|
|
|
|
return memo;
|
|
};
|
|
|
|
/**
|
|
* Returns a representation of the inverted index ready for serialisation.
|
|
*
|
|
* @return {Object}
|
|
* @memberOf InvertedIndex
|
|
*/
|
|
elasticlunr.InvertedIndex.prototype.toJSON = function () {
|
|
return {
|
|
root: this.root,
|
|
};
|
|
};
|
|
|
|
/*!
|
|
* elasticlunr.Configuration
|
|
* Copyright (C) 2017 Wei Song
|
|
*/
|
|
|
|
/**
|
|
* elasticlunr.Configuration is used to analyze the user search configuration.
|
|
*
|
|
* By elasticlunr.Configuration user could set query-time boosting, boolean model in each field.
|
|
*
|
|
* Currently configuration supports:
|
|
* 1. query-time boosting, user could set how to boost each field.
|
|
* 2. boolean model chosing, user could choose which boolean model to use for each field.
|
|
* 3. token expandation, user could set token expand to True to improve Recall. Default is False.
|
|
*
|
|
* Query time boosting must be configured by field category, "boolean" model could be configured
|
|
* by both field category or globally as the following example. Field configuration for "boolean"
|
|
* will overwrite global configuration.
|
|
* Token expand could be configured both by field category or golbally. Local field configuration will
|
|
* overwrite global configuration.
|
|
*
|
|
* configuration example:
|
|
* {
|
|
* fields:{
|
|
* title: {boost: 2},
|
|
* body: {boost: 1}
|
|
* },
|
|
* bool: "OR"
|
|
* }
|
|
*
|
|
* "bool" field configuation overwrite global configuation example:
|
|
* {
|
|
* fields:{
|
|
* title: {boost: 2, bool: "AND"},
|
|
* body: {boost: 1}
|
|
* },
|
|
* bool: "OR"
|
|
* }
|
|
*
|
|
* "expand" example:
|
|
* {
|
|
* fields:{
|
|
* title: {boost: 2, bool: "AND"},
|
|
* body: {boost: 1}
|
|
* },
|
|
* bool: "OR",
|
|
* expand: true
|
|
* }
|
|
*
|
|
* "expand" example for field category:
|
|
* {
|
|
* fields:{
|
|
* title: {boost: 2, bool: "AND", expand: true},
|
|
* body: {boost: 1}
|
|
* },
|
|
* bool: "OR"
|
|
* }
|
|
*
|
|
* setting the boost to 0 ignores the field (this will only search the title):
|
|
* {
|
|
* fields:{
|
|
* title: {boost: 1},
|
|
* body: {boost: 0}
|
|
* }
|
|
* }
|
|
*
|
|
* then, user could search with configuration to do query-time boosting.
|
|
* idx.search('oracle database', {fields: {title: {boost: 2}, body: {boost: 1}}});
|
|
*
|
|
*
|
|
* @constructor
|
|
*
|
|
* @param {String} config user configuration
|
|
* @param {Array} fields fields of index instance
|
|
* @module
|
|
*/
|
|
elasticlunr.Configuration = function (config, fields) {
|
|
var config = config || '';
|
|
|
|
if (fields == undefined || fields == null) {
|
|
throw new Error('fields should not be null');
|
|
}
|
|
|
|
this.config = {};
|
|
|
|
let userConfig;
|
|
try {
|
|
userConfig = JSON.parse(config);
|
|
this.buildUserConfig(userConfig, fields);
|
|
} catch (error) {
|
|
elasticlunr.utils.warn(
|
|
'user configuration parse failed, will use default configuration'
|
|
);
|
|
this.buildDefaultConfig(fields);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Build default search configuration.
|
|
*
|
|
* @param {Array} fields fields of index instance
|
|
*/
|
|
elasticlunr.Configuration.prototype.buildDefaultConfig = function (fields) {
|
|
this.reset();
|
|
fields.forEach(function (field) {
|
|
this.config[field] = {
|
|
boost: 1,
|
|
bool: 'OR',
|
|
expand: false,
|
|
};
|
|
}, this);
|
|
};
|
|
|
|
/**
|
|
* Build user configuration.
|
|
*
|
|
* @param {JSON} config User JSON configuratoin
|
|
* @param {Array} fields fields of index instance
|
|
*/
|
|
elasticlunr.Configuration.prototype.buildUserConfig = function (config, fields) {
|
|
let global_bool = 'OR';
|
|
let global_expand = false;
|
|
|
|
this.reset();
|
|
if ('bool' in config) {
|
|
global_bool = config.bool || global_bool;
|
|
}
|
|
|
|
if ('expand' in config) {
|
|
global_expand = config.expand || global_expand;
|
|
}
|
|
|
|
if ('fields' in config) {
|
|
for (const field in config.fields) {
|
|
if (fields.indexOf(field) > -1) {
|
|
const field_config = config.fields[field];
|
|
let field_expand = global_expand;
|
|
if (field_config.expand != undefined) {
|
|
field_expand = field_config.expand;
|
|
}
|
|
|
|
this.config[field] = {
|
|
boost:
|
|
field_config.boost || field_config.boost === 0
|
|
? field_config.boost
|
|
: 1,
|
|
bool: field_config.bool || global_bool,
|
|
expand: field_expand,
|
|
};
|
|
} else {
|
|
elasticlunr.utils.warn(
|
|
'field name in user configuration not found in index instance fields'
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
this.addAllFields2UserConfig(global_bool, global_expand, fields);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Add all fields to user search configuration.
|
|
*
|
|
* @param {String} bool Boolean model
|
|
* @param {String} expand Expand model
|
|
* @param {Array} fields fields of index instance
|
|
*/
|
|
elasticlunr.Configuration.prototype.addAllFields2UserConfig = function (
|
|
bool,
|
|
expand,
|
|
fields
|
|
) {
|
|
fields.forEach(function (field) {
|
|
this.config[field] = {
|
|
boost: 1,
|
|
bool,
|
|
expand,
|
|
};
|
|
}, this);
|
|
};
|
|
|
|
/**
|
|
* get current user configuration
|
|
*/
|
|
elasticlunr.Configuration.prototype.get = function () {
|
|
return this.config;
|
|
};
|
|
|
|
/**
|
|
* reset user search configuration.
|
|
*/
|
|
elasticlunr.Configuration.prototype.reset = function () {
|
|
this.config = {};
|
|
};
|
|
/**
|
|
* sorted_set.js is added only to make elasticlunr.js compatible with lunr-languages.
|
|
* if elasticlunr.js support different languages by default, this will make elasticlunr.js
|
|
* much bigger that not good for browser usage.
|
|
*
|
|
*/
|
|
|
|
/*!
|
|
* lunr.SortedSet
|
|
* Copyright (C) 2017 Oliver Nightingale
|
|
*/
|
|
|
|
/**
|
|
* lunr.SortedSets are used to maintain an array of uniq values in a sorted
|
|
* order.
|
|
*
|
|
* @constructor
|
|
*/
|
|
lunr.SortedSet = function () {
|
|
this.length = 0;
|
|
this.elements = [];
|
|
};
|
|
|
|
/**
|
|
* Loads a previously serialised sorted set.
|
|
*
|
|
* @param {Array} serialisedData The serialised set to load.
|
|
* @returns {lunr.SortedSet}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.load = function (serialisedData) {
|
|
const set = new this();
|
|
|
|
set.elements = serialisedData;
|
|
set.length = serialisedData.length;
|
|
|
|
return set;
|
|
};
|
|
|
|
/**
|
|
* Inserts new items into the set in the correct position to maintain the
|
|
* order.
|
|
*
|
|
* @param {Object} The objects to add to this set.
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.add = function () {
|
|
let i, element;
|
|
|
|
for (i = 0; i < arguments.length; i++) {
|
|
element = arguments[i];
|
|
if (~this.indexOf(element)) continue;
|
|
this.elements.splice(this.locationFor(element), 0, element);
|
|
}
|
|
|
|
this.length = this.elements.length;
|
|
};
|
|
|
|
/**
|
|
* Converts this sorted set into an array.
|
|
*
|
|
* @returns {Array}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.toArray = function () {
|
|
return this.elements.slice();
|
|
};
|
|
|
|
/**
|
|
* Creates a new array with the results of calling a provided function on every
|
|
* element in this sorted set.
|
|
*
|
|
* Delegates to Array.prototype.map and has the same signature.
|
|
*
|
|
* @param {Function} fn The function that is called on each element of the
|
|
* set.
|
|
* @param {Object} ctx An optional object that can be used as the context
|
|
* for the function fn.
|
|
* @returns {Array}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.map = function (fn, ctx) {
|
|
return this.elements.map(fn, ctx);
|
|
};
|
|
|
|
/**
|
|
* Executes a provided function once per sorted set element.
|
|
*
|
|
* Delegates to Array.prototype.forEach and has the same signature.
|
|
*
|
|
* @param {Function} fn The function that is called on each element of the
|
|
* set.
|
|
* @param {Object} ctx An optional object that can be used as the context
|
|
* @memberOf SortedSet
|
|
* for the function fn.
|
|
*/
|
|
lunr.SortedSet.prototype.forEach = function (fn, ctx) {
|
|
return this.elements.forEach(fn, ctx);
|
|
};
|
|
|
|
/**
|
|
* Returns the index at which a given element can be found in the
|
|
* sorted set, or -1 if it is not present.
|
|
*
|
|
* @param {Object} elem The object to locate in the sorted set.
|
|
* @returns {Number}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.indexOf = function (elem) {
|
|
let start = 0;
|
|
let end = this.elements.length;
|
|
let sectionLength = end - start;
|
|
let pivot = start + Math.floor(sectionLength / 2);
|
|
let pivotElem = this.elements[pivot];
|
|
|
|
while (sectionLength > 1) {
|
|
if (pivotElem === elem) return pivot;
|
|
|
|
if (pivotElem < elem) start = pivot;
|
|
if (pivotElem > elem) end = pivot;
|
|
|
|
sectionLength = end - start;
|
|
pivot = start + Math.floor(sectionLength / 2);
|
|
pivotElem = this.elements[pivot];
|
|
}
|
|
|
|
if (pivotElem === elem) return pivot;
|
|
|
|
return -1;
|
|
};
|
|
|
|
/**
|
|
* Returns the position within the sorted set that an element should be
|
|
* inserted at to maintain the current order of the set.
|
|
*
|
|
* This function assumes that the element to search for does not already exist
|
|
* in the sorted set.
|
|
*
|
|
* @param {Object} elem The elem to find the position for in the set
|
|
* @returns {Number}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.locationFor = function (elem) {
|
|
let start = 0;
|
|
let end = this.elements.length;
|
|
let sectionLength = end - start;
|
|
let pivot = start + Math.floor(sectionLength / 2);
|
|
let pivotElem = this.elements[pivot];
|
|
|
|
while (sectionLength > 1) {
|
|
if (pivotElem < elem) start = pivot;
|
|
if (pivotElem > elem) end = pivot;
|
|
|
|
sectionLength = end - start;
|
|
pivot = start + Math.floor(sectionLength / 2);
|
|
pivotElem = this.elements[pivot];
|
|
}
|
|
|
|
if (pivotElem > elem) return pivot;
|
|
if (pivotElem < elem) return pivot + 1;
|
|
};
|
|
|
|
/**
|
|
* Creates a new lunr.SortedSet that contains the elements in the intersection
|
|
* of this set and the passed set.
|
|
*
|
|
* @param {lunr.SortedSet} otherSet The set to intersect with this set.
|
|
* @returns {lunr.SortedSet}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.intersect = function (otherSet) {
|
|
const intersectSet = new lunr.SortedSet();
|
|
let i = 0;
|
|
let j = 0;
|
|
const a_len = this.length;
|
|
const b_len = otherSet.length;
|
|
const a = this.elements;
|
|
const b = otherSet.elements;
|
|
|
|
while (true) {
|
|
if (i > a_len - 1 || j > b_len - 1) break;
|
|
|
|
if (a[i] === b[j]) {
|
|
intersectSet.add(a[i]);
|
|
i++, j++;
|
|
continue;
|
|
}
|
|
|
|
if (a[i] < b[j]) {
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (a[i] > b[j]) {
|
|
j++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return intersectSet;
|
|
};
|
|
|
|
/**
|
|
* Makes a copy of this set
|
|
*
|
|
* @returns {lunr.SortedSet}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.clone = function () {
|
|
const clone = new lunr.SortedSet();
|
|
|
|
clone.elements = this.toArray();
|
|
clone.length = clone.elements.length;
|
|
|
|
return clone;
|
|
};
|
|
|
|
/**
|
|
* Creates a new lunr.SortedSet that contains the elements in the union
|
|
* of this set and the passed set.
|
|
*
|
|
* @param {lunr.SortedSet} otherSet The set to union with this set.
|
|
* @returns {lunr.SortedSet}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.union = function (otherSet) {
|
|
let longSet, shortSet, unionSet;
|
|
|
|
if (this.length >= otherSet.length) {
|
|
(longSet = this), (shortSet = otherSet);
|
|
} else {
|
|
(longSet = otherSet), (shortSet = this);
|
|
}
|
|
|
|
unionSet = longSet.clone();
|
|
|
|
for (
|
|
let i = 0, shortSetElements = shortSet.toArray();
|
|
i < shortSetElements.length;
|
|
i++
|
|
) {
|
|
unionSet.add(shortSetElements[i]);
|
|
}
|
|
|
|
return unionSet;
|
|
};
|
|
|
|
/**
|
|
* Returns a representation of the sorted set ready for serialisation.
|
|
*
|
|
* @returns {Array}
|
|
* @memberOf SortedSet
|
|
*/
|
|
lunr.SortedSet.prototype.toJSON = function () {
|
|
return this.toArray();
|
|
};
|
|
/**
|
|
* export the module via AMD, CommonJS or as a browser global
|
|
* Export code from https://github.com/umdjs/umd/blob/master/returnExports.js
|
|
*/
|
|
(function (root, factory) {
|
|
if (typeof define === 'function' && define.amd) {
|
|
// AMD. Register as an anonymous module.
|
|
define(factory);
|
|
} else if (typeof exports === 'object') {
|
|
/**
|
|
* Node. Does not work with strict CommonJS, but
|
|
* only CommonJS-like enviroments that support module.exports,
|
|
* like Node.
|
|
*/
|
|
module.exports = factory();
|
|
} else {
|
|
// Browser globals (root is window)
|
|
root.elasticlunr = factory();
|
|
}
|
|
})(this, function () {
|
|
/**
|
|
* Just return a value to define the module export.
|
|
* This example returns an object, but the module
|
|
* can return a function as the exported value.
|
|
*/
|
|
return elasticlunr;
|
|
});
|
|
})();
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - //
|
|
// End of elasticlunr code (http://elasticlunr.com/elasticlunr.js) //
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - //
|
|
|
|
window.onload = function () {
|
|
if (!document.body.contains(document.getElementById('searchModal'))) {
|
|
return;
|
|
}
|
|
|
|
const results = document.getElementById('results');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const searchModal = document.getElementById('searchModal');
|
|
const searchButton = document.getElementById('search-button');
|
|
const nResultsSpan = document.getElementById('n-results');
|
|
const clearSearchButton = document.getElementById('clear-search');
|
|
const resultsContainer = document.getElementById('results-container');
|
|
|
|
// Replace $SHORTCUT in search icon title with actual OS-specific shortcut.
|
|
function getShortcut() {
|
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
|
if (userAgent.includes('mac')) {
|
|
return 'Cmd + K';
|
|
} else {
|
|
return 'Ctrl + K';
|
|
}
|
|
}
|
|
|
|
function setAttributes(element, attributeNames) {
|
|
const shortcut = getShortcut();
|
|
attributeNames.forEach((attributeName) => {
|
|
let attributeValue = element.getAttribute(attributeName);
|
|
if (attributeValue) {
|
|
attributeValue = attributeValue.replace('$SHORTCUT', shortcut);
|
|
element.setAttribute(attributeName, attributeValue);
|
|
}
|
|
});
|
|
}
|
|
setAttributes(searchButton, ['title', 'aria-label']);
|
|
|
|
// Make search button keyboard accessible.
|
|
searchButton.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
searchButton.click();
|
|
}
|
|
});
|
|
|
|
let lastFocusedElement;
|
|
function openSearchModal() {
|
|
lastFocusedElement = document.activeElement;
|
|
loadSearchIndex();
|
|
searchModal.style.display = 'block';
|
|
searchInput.focus();
|
|
}
|
|
|
|
function closeModal() {
|
|
searchModal.style.display = 'none';
|
|
clearSearch();
|
|
if (lastFocusedElement && document.body.contains(lastFocusedElement)) {
|
|
lastFocusedElement.focus();
|
|
}
|
|
}
|
|
|
|
function toggleModalVisibility() {
|
|
const isModalOpen = searchModal.style.display === 'block';
|
|
if (isModalOpen) {
|
|
closeModal();
|
|
} else {
|
|
openSearchModal();
|
|
}
|
|
}
|
|
|
|
// Function to remove 'selected' class from all divs except the one passed.
|
|
function clearSelected(exceptDiv = null) {
|
|
const divs = results.querySelectorAll('#results > div');
|
|
divs.forEach((div) => {
|
|
if (div !== exceptDiv) {
|
|
div.setAttribute('aria-selected', 'false');
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateSelection(div) {
|
|
if (div.getAttribute('aria-selected') !== 'true') {
|
|
clearSelected(div);
|
|
div.setAttribute('aria-selected', 'true');
|
|
}
|
|
searchInput.setAttribute('aria-activedescendant', div.id);
|
|
}
|
|
|
|
function clearSearch() {
|
|
searchInput.value = '';
|
|
results.innerHTML = '';
|
|
nResultsSpan.textContent = '0';
|
|
resultsContainer.style.display = 'none';
|
|
searchInput.removeAttribute('aria-activedescendant');
|
|
}
|
|
|
|
// Close modal when clicking/tapping outside.
|
|
function handleModalInteraction(event) {
|
|
if (event.target === searchModal) {
|
|
closeModal();
|
|
}
|
|
event.stopPropagation(); // Prevents tapping through the modal.
|
|
}
|
|
searchModal.addEventListener('click', handleModalInteraction);
|
|
searchModal.addEventListener('touchend', handleModalInteraction);
|
|
|
|
// Close modal when pressing escape.
|
|
document.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Escape') {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
clearSearchButton.addEventListener('click', function () {
|
|
clearSearch();
|
|
searchInput.focus();
|
|
});
|
|
clearSearchButton.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
clearSearch();
|
|
searchInput.focus();
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
// The index loads on mouseover/tap.
|
|
// Clicking/tapping the search button opens the modal.
|
|
searchButton.addEventListener('mouseover', loadSearchIndex);
|
|
searchButton.addEventListener('click', openSearchModal);
|
|
searchButton.addEventListener('touchstart', openSearchModal);
|
|
|
|
let searchIndexPromise = null;
|
|
function loadSearchIndex() {
|
|
if (!searchIndexPromise) {
|
|
// Check if the search index is already loaded in the window object
|
|
if (window.searchIndex) {
|
|
// If the index is pre-loaded, use it directly.
|
|
searchIndexPromise = Promise.resolve(
|
|
elasticlunr.Index.load(window.searchIndex)
|
|
);
|
|
} else {
|
|
// If the index is not pre-loaded, fetch it from the JSON file.
|
|
const language = document.documentElement
|
|
.getAttribute('lang')
|
|
.substring(0, 2);
|
|
let basePath = document
|
|
.querySelector("meta[name='base']")
|
|
.getAttribute('content');
|
|
if (basePath.endsWith('/')) {
|
|
basePath = basePath.slice(0, -1);
|
|
}
|
|
|
|
searchIndexPromise = fetch(
|
|
basePath + '/search_index.' + language + '.json'
|
|
)
|
|
.then((response) => response.json())
|
|
.then((json) => elasticlunr.Index.load(json));
|
|
}
|
|
}
|
|
}
|
|
|
|
function getByteByBinary(binaryCode) {
|
|
// Binary system, starts with `0b` in ES6
|
|
// Octal number system, starts with `0` in ES5 and starts with `0o` in ES6
|
|
// Hexadecimal, starts with `0x` in both ES5 and ES6
|
|
var byteLengthDatas = [0, 1, 2, 3, 4];
|
|
var len = byteLengthDatas[Math.ceil(binaryCode.length / 8)];
|
|
return len;
|
|
}
|
|
|
|
function getByteByHex(hexCode) {
|
|
return getByteByBinary(parseInt(hexCode, 16).toString(2));
|
|
}
|
|
|
|
function substringByByte(str, maxLength) {
|
|
let result = '';
|
|
let flag = false;
|
|
let len = 0;
|
|
let length = 0;
|
|
let length2 = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const code = str.codePointAt(i).toString(16);
|
|
if (code.length > 4) {
|
|
i++;
|
|
if (i + 1 < str.length) {
|
|
flag = str.codePointAt(i + 1).toString(16) === '200d';
|
|
}
|
|
}
|
|
if (flag) {
|
|
len += getByteByHex(code);
|
|
if (i == str.length - 1) {
|
|
length += len;
|
|
if (length <= maxLength) {
|
|
result += str.substr(length2, i - length2 + 1);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
if (len != 0) {
|
|
length += len;
|
|
length += getByteByHex(code);
|
|
if (length <= maxLength) {
|
|
result += str.substr(length2, i - length2 + 1);
|
|
length2 = i + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
len = 0;
|
|
continue;
|
|
}
|
|
length += getByteByHex(code);
|
|
if (length <= maxLength) {
|
|
if (code.length <= 4) {
|
|
result += str[i];
|
|
} else {
|
|
result += str[i - 1] + str[i];
|
|
}
|
|
length2 = i + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function generateSnippet(text, searchTerms) {
|
|
const BASE_SCORE = 2;
|
|
const FIRST_WORD_SCORE = 8;
|
|
const HIGHLIGHT_SCORE = 40;
|
|
const PRE_MATCH_CONTEXT_WORDS = 4;
|
|
const SNIPPET_LENGTH = 150;
|
|
const WINDOW_SIZE = 30;
|
|
|
|
const stemmedTerms = searchTerms.map(function (term) {
|
|
return elasticlunr.stemmer(term.toLowerCase());
|
|
});
|
|
|
|
let totalLength = 0;
|
|
const tokenScores = [];
|
|
const sentences = text.toLowerCase().split('. ');
|
|
|
|
for (const sentence of sentences) {
|
|
const words = sentence.split(/[\s\n]/);
|
|
let isFirstWord = true;
|
|
|
|
for (const word of words) {
|
|
if (word.length > 0) {
|
|
let score = isFirstWord ? FIRST_WORD_SCORE : BASE_SCORE;
|
|
for (const stemmedTerm of stemmedTerms) {
|
|
if (elasticlunr.stemmer(word).startsWith(stemmedTerm)) {
|
|
score = HIGHLIGHT_SCORE;
|
|
}
|
|
}
|
|
tokenScores.push([word, score, totalLength]);
|
|
isFirstWord = false;
|
|
}
|
|
totalLength += word.length + 1;
|
|
}
|
|
totalLength += 1;
|
|
}
|
|
|
|
if (tokenScores.length === 0) {
|
|
return text.length > SNIPPET_LENGTH
|
|
? text.substring(0, SNIPPET_LENGTH) + '…'
|
|
: text;
|
|
}
|
|
|
|
const scores = [];
|
|
let windowScore = 0;
|
|
|
|
for (var i = 0; i < Math.min(tokenScores.length, WINDOW_SIZE); i++) {
|
|
windowScore += tokenScores[i][1];
|
|
}
|
|
scores.push(windowScore);
|
|
|
|
// Slide the window and update the score.
|
|
for (var i = 1; i <= tokenScores.length - WINDOW_SIZE; i++) {
|
|
windowScore -= tokenScores[i - 1][1];
|
|
windowScore += tokenScores[i + WINDOW_SIZE - 1][1];
|
|
scores.push(windowScore);
|
|
}
|
|
|
|
let maxScoreIndex = 0;
|
|
let maxScore = 0;
|
|
for (var i = scores.length - 1; i >= 0; i--) {
|
|
if (maxScore < scores[i]) {
|
|
maxScore = scores[i];
|
|
maxScoreIndex = i;
|
|
}
|
|
}
|
|
|
|
const snippet = [];
|
|
// From my testing, the context is more clear if we start a few words back.
|
|
let start = adjustStartPos(
|
|
text,
|
|
tokenScores[maxScoreIndex][2],
|
|
PRE_MATCH_CONTEXT_WORDS
|
|
);
|
|
|
|
function adjustStartPos(text, matchStartIndex, numWordsBack) {
|
|
let spaceCount = 0;
|
|
let index = matchStartIndex - 1;
|
|
while (index >= 0 && spaceCount < numWordsBack) {
|
|
if (text[index] === ' ' && text[index - 1] !== '.') {
|
|
spaceCount++;
|
|
} else if (text[index] === '.' && text[index + 1] === ' ') {
|
|
// Stop if the match is at the start of a sentence.
|
|
break;
|
|
}
|
|
index--;
|
|
}
|
|
return spaceCount === numWordsBack ? index + 1 : matchStartIndex;
|
|
}
|
|
const re = /^[\x00-\xff]+$/; // Regular expression for ASCII check.
|
|
for (
|
|
var i = maxScoreIndex;
|
|
i < maxScoreIndex + WINDOW_SIZE && i < tokenScores.length;
|
|
i++
|
|
) {
|
|
const wordData = tokenScores[i];
|
|
if (start < wordData[2]) {
|
|
snippet.push(text.substring(start, wordData[2]));
|
|
start = wordData[2];
|
|
}
|
|
|
|
if (wordData[1] === HIGHLIGHT_SCORE) {
|
|
snippet.push('<b>');
|
|
}
|
|
const end = wordData[2] + wordData[0].length;
|
|
// Handle non-ASCII characters.
|
|
if (!re.test(wordData[0]) && wordData[0].length >= 12) {
|
|
const strBefore = text.substring(wordData[2], end);
|
|
const strAfter = substringByByte(strBefore, 12);
|
|
snippet.push(strAfter);
|
|
} else {
|
|
snippet.push(text.substring(wordData[2], end));
|
|
}
|
|
|
|
if (wordData[1] === HIGHLIGHT_SCORE) {
|
|
snippet.push('</b>');
|
|
}
|
|
start = end;
|
|
}
|
|
|
|
snippet.push('…');
|
|
const joinedSnippet = snippet.join('');
|
|
let truncatedSnippet = joinedSnippet;
|
|
if (joinedSnippet.replace(/<[^>]+>/g, '').length > SNIPPET_LENGTH) {
|
|
truncatedSnippet = joinedSnippet.substring(0, SNIPPET_LENGTH) + '…';
|
|
}
|
|
|
|
return truncatedSnippet;
|
|
}
|
|
|
|
// Handle input in the search box.
|
|
searchInput.addEventListener(
|
|
'input',
|
|
async function () {
|
|
const searchTerm = this.value.trim();
|
|
const searchIndex = await searchIndexPromise;
|
|
results.innerHTML = '';
|
|
|
|
resultsContainer.style.display = searchTerm.length > 0 ? 'block' : 'none';
|
|
|
|
// Perform the search and store the results.
|
|
const searchResults = searchIndex.search(searchTerm, {
|
|
bool: 'OR',
|
|
fields: {
|
|
title: { boost: 3 },
|
|
body: { boost: 2 },
|
|
description: { boost: 1 },
|
|
path: { boost: 1 },
|
|
},
|
|
});
|
|
|
|
// Update the number of results.
|
|
updateResultText(searchResults.length);
|
|
|
|
// Display the results.
|
|
let resultIdCounter = 0; // Counter to generate unique IDs.
|
|
searchResults.forEach(function (result) {
|
|
if (result.doc.title || result.doc.path || result.doc.id) {
|
|
const resultDiv = document.createElement('div');
|
|
resultDiv.setAttribute('role', 'option');
|
|
resultDiv.id = 'result-' + resultIdCounter++;
|
|
resultDiv.innerHTML = '<a href><span></span><span></span></a>';
|
|
const linkElement = resultDiv.querySelector('a');
|
|
const titleElement = resultDiv.querySelector('span:first-child');
|
|
const snippetElement = resultDiv.querySelector('span:nth-child(2)');
|
|
|
|
// Determine the text for the title.
|
|
titleElement.textContent =
|
|
result.doc.title || result.doc.path || result.doc.id;
|
|
|
|
// Determine if the body or description is available for the snippet.
|
|
let snippetText = result.doc.body
|
|
? generateSnippet(result.doc.body, searchTerm.split(/\s+/))
|
|
: result.doc.description
|
|
? result.doc.description
|
|
: '';
|
|
snippetElement.innerHTML = snippetText;
|
|
|
|
// Create the hyperlink.
|
|
let href = result.ref;
|
|
if (result.doc.body) {
|
|
// Include text fragment if body is available.
|
|
const encodedSearchTerm = encodeURIComponent(searchTerm);
|
|
href += `#:~:text=${encodedSearchTerm}`;
|
|
}
|
|
linkElement.href = href;
|
|
|
|
results.appendChild(resultDiv);
|
|
}
|
|
});
|
|
|
|
searchInput.setAttribute(
|
|
'aria-expanded',
|
|
resultIdCounter > 0 ? 'true' : 'false'
|
|
);
|
|
|
|
if (results.firstChild) {
|
|
updateSelection(results.firstChild);
|
|
}
|
|
|
|
results.addEventListener('mouseover', function (event) {
|
|
if (event.target.closest('div[role="option"]')) {
|
|
updateSelection(event.target.closest('div[role="option"]'));
|
|
}
|
|
});
|
|
|
|
// Add touch events to the results.
|
|
setupTouchEvents();
|
|
},
|
|
true
|
|
);
|
|
|
|
function updateResultText(count) {
|
|
const nResultsSpan = document.getElementById('n-results');
|
|
nResultsSpan.textContent = count.toString();
|
|
|
|
const singular = document.getElementById('result-text-singular');
|
|
const plural = document.getElementById('result-text-plural');
|
|
|
|
if (count === 1) {
|
|
singular.style.display = 'inline';
|
|
plural.style.display = 'none';
|
|
} else {
|
|
singular.style.display = 'none';
|
|
plural.style.display = 'inline';
|
|
}
|
|
}
|
|
|
|
function setupTouchEvents() {
|
|
const resultDivs = document.querySelectorAll('#results > div');
|
|
resultDivs.forEach((div) => {
|
|
// Remove existing listener to avoid duplicates.
|
|
div.removeEventListener('touchstart', handleTouchStart);
|
|
div.addEventListener('touchstart', handleTouchStart);
|
|
});
|
|
}
|
|
|
|
function handleTouchStart() {
|
|
updateSelection(this);
|
|
}
|
|
|
|
// Handle keyboard navigation.
|
|
document.addEventListener('keydown', function (event) {
|
|
// Add handling for the modal open/close shortcut.
|
|
const isMac = navigator.userAgent.toLowerCase().includes('mac');
|
|
const MODAL_SHORTCUT_KEY = 'k';
|
|
const modalShortcutModifier = isMac ? event.metaKey : event.ctrlKey;
|
|
|
|
if (event.key === MODAL_SHORTCUT_KEY && modalShortcutModifier) {
|
|
event.preventDefault();
|
|
toggleModalVisibility();
|
|
return;
|
|
}
|
|
|
|
const activeElement = document.activeElement;
|
|
if (
|
|
event.key === 'Tab' &&
|
|
(activeElement === searchInput || activeElement === clearSearchButton)
|
|
) {
|
|
event.preventDefault();
|
|
const nextFocusableElement =
|
|
activeElement === searchInput ? clearSearchButton : searchInput;
|
|
nextFocusableElement.focus();
|
|
return;
|
|
}
|
|
|
|
function updateResultSelection(newIndex, divsArray) {
|
|
updateSelection(divsArray[newIndex]);
|
|
divsArray[newIndex].scrollIntoView({ block: 'nearest', inline: 'start' });
|
|
}
|
|
|
|
const resultDivs = results.querySelectorAll('#results > div');
|
|
if (resultDivs.length === 0) return;
|
|
|
|
const divsArray = Array.from(resultDivs);
|
|
let activeDiv = results.querySelector('[aria-selected="true"]');
|
|
let activeDivIndex = divsArray.indexOf(activeDiv);
|
|
|
|
if (
|
|
['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(
|
|
event.key
|
|
)
|
|
) {
|
|
event.preventDefault();
|
|
let newIndex = activeDivIndex;
|
|
|
|
switch (event.key) {
|
|
case 'ArrowUp':
|
|
newIndex = Math.max(activeDivIndex - 1, 0);
|
|
break;
|
|
case 'ArrowDown':
|
|
newIndex = Math.min(activeDivIndex + 1, divsArray.length - 1);
|
|
break;
|
|
case 'Home':
|
|
newIndex = 0;
|
|
break;
|
|
case 'End':
|
|
newIndex = divsArray.length - 1;
|
|
break;
|
|
case 'PageUp':
|
|
newIndex = Math.max(activeDivIndex - 3, 0);
|
|
break;
|
|
case 'PageDown':
|
|
newIndex = Math.min(activeDivIndex + 3, divsArray.length - 1);
|
|
break;
|
|
}
|
|
|
|
if (newIndex !== activeDivIndex) {
|
|
updateResultSelection(newIndex, divsArray);
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Enter' && activeDiv) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
const anchorTag = activeDiv.querySelector('a');
|
|
if (anchorTag) {
|
|
window.location.href = anchorTag.getAttribute('href');
|
|
}
|
|
closeModal(); // Necessary when linking to the current page.
|
|
}
|
|
});
|
|
};
|