/** * 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 lang = document.documentElement.lang; const searchInput = document.getElementById('searchInput'); const searchModal = document.getElementById('searchModal'); const searchButton = document.getElementById('search-button'); const clearSearchButton = document.getElementById('clear-search'); const resultsContainer = document.getElementById('results-container'); const results = document.getElementById('results'); // Get all spans holding the translated strings, even if they are only used on one language. const zeroResultsSpan = document.getElementById('zero_results'); const oneResultsSpan = document.getElementById('one_results'); const twoResultsSpan = document.getElementById('two_results'); const fewResultsSpan = document.getElementById('few_results'); const manyResultsSpan = document.getElementById('many_results'); // Static mapping of keys to spans. const resultSpans = { zero_results: zeroResultsSpan, one_results: oneResultsSpan, two_results: twoResultsSpan, few_results: fewResultsSpan, many_results: manyResultsSpan, }; // 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 = ''; resultsContainer.style.display = 'none'; searchInput.removeAttribute('aria-activedescendant'); clearSearchButton.style.display = 'none'; } // 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, { passive: true }); // 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, { passive: true }); 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(''); } 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(''); } 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 inputValue = this.value; const searchTerm = inputValue.trim(); const searchIndex = await searchIndexPromise; results.innerHTML = ''; // Use the raw input so the "clear" button appears even if there's only spaces. clearSearchButton.style.display = inputValue.length > 0 ? 'block' : 'none'; 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 = ''; 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"]')); } }); results.addEventListener('click', function(event) { const clickedElement = event.target.closest('a'); if (clickedElement) { const clickedHref = clickedElement.getAttribute('href'); const currentPageUrl = window.location.href; // Normalise URLs by removing the text fragment and trailing slash. const normalizeUrl = (url) => url.split('#')[0].replace(/\/$/, ''); // Check if the clicked link matches the current page. // If using Ctrl+click or Cmd+click, don't close the modal. if (normalizeUrl(clickedHref) === normalizeUrl(currentPageUrl) && !event.ctrlKey && !event.metaKey) { closeModal(); } } }); // Add touch events to the results. setupTouchEvents(); }, true ); function updateResultText(count) { // Determine the correct pluralization key based on count and language. const pluralizationKey = getPluralizationKey(count, lang); // Hide all result text spans. Object.values(resultSpans).forEach((span) => { if (span) span.style.display = 'none'; }); // Show the relevant result text span, replacing $NUMBER with the actual count. const activeSpan = resultSpans[pluralizationKey]; if (activeSpan) { activeSpan.style.display = 'inline'; activeSpan.textContent = activeSpan.textContent.replace( '$NUMBER', count.toString() ); } } function getPluralizationKey(count, lang) { let key = ''; const slavicLangs = ['uk', 'be', 'bs', 'hr', 'ru', 'sr']; // Common cases: zero, one. if (count === 0) { key = 'zero_results'; } else if (count === 1) { key = 'one_results'; } else { // Arabic. if (lang === 'ar') { let modulo = count % 100; if (count === 2) { key = 'two_results'; } else if (modulo >= 3 && modulo <= 10) { key = 'few_results'; } else { key = 'many_results'; } } else if (slavicLangs.includes(lang)) { // Slavic languages. let modulo10 = count % 10; let modulo100 = count % 100; if (modulo10 === 1 && modulo100 !== 11) { key = 'one_results'; } else if ( modulo10 >= 2 && modulo10 <= 4 && !(modulo100 >= 12 && modulo100 <= 14) ) { key = 'few_results'; } else { key = 'many_results'; } } else { key = 'many_results'; // Default plural. } } return key; } 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, { passive: true }); }); } 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. } }); };