js-search
Version:
JS Search is an efficient, client-side search library for JavaScript and JSON objects
887 lines (742 loc) • 23.6 kB
JavaScript
/**
* Indexes for all substring searches (e.g. the term "cat" is indexed as "c", "ca", "cat", "a", "at", and "t").
*/
var AllSubstringsIndexStrategy = /*#__PURE__*/function () {
function AllSubstringsIndexStrategy() {}
var _proto = AllSubstringsIndexStrategy.prototype;
/**
* @inheritDocs
*/
_proto.expandToken = function expandToken(token) {
var expandedTokens = [];
var string;
for (var i = 0, length = token.length; i < length; ++i) {
string = '';
for (var j = i; j < length; ++j) {
string += token.charAt(j);
expandedTokens.push(string);
}
}
return expandedTokens;
};
return AllSubstringsIndexStrategy;
}();
/**
* Indexes for exact word matches.
*/
var ExactWordIndexStrategy = /*#__PURE__*/function () {
function ExactWordIndexStrategy() {}
var _proto = ExactWordIndexStrategy.prototype;
/**
* @inheritDocs
*/
_proto.expandToken = function expandToken(token) {
return token ? [token] : [];
};
return ExactWordIndexStrategy;
}();
/**
* Indexes for prefix searches (e.g. the term "cat" is indexed as "c", "ca", and "cat" allowing prefix search lookups).
*/
var PrefixIndexStrategy = /*#__PURE__*/function () {
function PrefixIndexStrategy() {}
var _proto = PrefixIndexStrategy.prototype;
/**
* @inheritDocs
*/
_proto.expandToken = function expandToken(token) {
var expandedTokens = [];
var string = '';
for (var i = 0, length = token.length; i < length; ++i) {
string += token.charAt(i);
expandedTokens.push(string);
}
return expandedTokens;
};
return PrefixIndexStrategy;
}();
/**
* Enforces case-sensitive text matches.
*/
var CaseSensitiveSanitizer = /*#__PURE__*/function () {
function CaseSensitiveSanitizer() {}
var _proto = CaseSensitiveSanitizer.prototype;
/**
* @inheritDocs
*/
_proto.sanitize = function sanitize(text) {
return text ? text.trim() : '';
};
return CaseSensitiveSanitizer;
}();
/**
* Sanitizes text by converting to a locale-friendly lower-case version and triming leading and trailing whitespace.
*/
var LowerCaseSanitizer = /*#__PURE__*/function () {
function LowerCaseSanitizer() {}
var _proto = LowerCaseSanitizer.prototype;
/**
* @inheritDocs
*/
_proto.sanitize = function sanitize(text) {
return text ? text.toLocaleLowerCase().trim() : '';
};
return LowerCaseSanitizer;
}();
/**
* Find and return a nested object value.
*
* @param object to crawl
* @param path Property path
* @returns {any}
*/
function getNestedFieldValue(object, path) {
path = path || [];
object = object || {};
var value = object; // walk down the property path
for (var i = 0; i < path.length; i++) {
value = value[path[i]];
if (value == null) {
return null;
}
}
return value;
}
/**
* Search index capable of returning results matching a set of tokens and ranked according to TF-IDF.
*/
var TfIdfSearchIndex = /*#__PURE__*/function () {
function TfIdfSearchIndex(uidFieldName) {
this._uidFieldName = uidFieldName;
this._tokenToIdfCache = {};
this._tokenMap = {};
}
/**
* @inheritDocs
*/
var _proto = TfIdfSearchIndex.prototype;
_proto.indexDocument = function indexDocument(token, uid, doc) {
this._tokenToIdfCache = {}; // New index invalidates previous IDF caches
var tokenMap = this._tokenMap;
var tokenDatum;
if (typeof tokenMap[token] !== 'object') {
tokenMap[token] = tokenDatum = {
$numDocumentOccurrences: 0,
$totalNumOccurrences: 1,
$uidMap: {}
};
} else {
tokenDatum = tokenMap[token];
tokenDatum.$totalNumOccurrences++;
}
var uidMap = tokenDatum.$uidMap;
if (typeof uidMap[uid] !== 'object') {
tokenDatum.$numDocumentOccurrences++;
uidMap[uid] = {
$document: doc,
$numTokenOccurrences: 1
};
} else {
uidMap[uid].$numTokenOccurrences++;
}
}
/**
* @inheritDocs
*/
;
_proto.search = function search(tokens, corpus) {
var uidToDocumentMap = {};
for (var i = 0, numTokens = tokens.length; i < numTokens; i++) {
var token = tokens[i];
var tokenMetadata = this._tokenMap[token]; // Short circuit if no matches were found for any given token.
if (!tokenMetadata) {
return [];
}
if (i === 0) {
var keys = Object.keys(tokenMetadata.$uidMap);
for (var j = 0, numKeys = keys.length; j < numKeys; j++) {
var uid = keys[j];
uidToDocumentMap[uid] = tokenMetadata.$uidMap[uid].$document;
}
} else {
var keys = Object.keys(uidToDocumentMap);
for (var j = 0, numKeys = keys.length; j < numKeys; j++) {
var uid = keys[j];
if (typeof tokenMetadata.$uidMap[uid] !== 'object') {
delete uidToDocumentMap[uid];
}
}
}
}
var documents = [];
for (var uid in uidToDocumentMap) {
documents.push(uidToDocumentMap[uid]);
}
var calculateTfIdf = this._createCalculateTfIdf(); // Return documents sorted by TF-IDF
return documents.sort(function (documentA, documentB) {
return calculateTfIdf(tokens, documentB, corpus) - calculateTfIdf(tokens, documentA, corpus);
});
};
_proto._createCalculateIdf = function _createCalculateIdf() {
var tokenMap = this._tokenMap;
var tokenToIdfCache = this._tokenToIdfCache;
return function calculateIdf(token, documents) {
if (!tokenToIdfCache[token]) {
var numDocumentsWithToken = typeof tokenMap[token] !== 'undefined' ? tokenMap[token].$numDocumentOccurrences : 0;
tokenToIdfCache[token] = 1 + Math.log(documents.length / (1 + numDocumentsWithToken));
}
return tokenToIdfCache[token];
};
};
_proto._createCalculateTfIdf = function _createCalculateTfIdf() {
var tokenMap = this._tokenMap;
var uidFieldName = this._uidFieldName;
var calculateIdf = this._createCalculateIdf();
return function calculateTfIdf(tokens, document, documents) {
var score = 0;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
var token = tokens[i];
var inverseDocumentFrequency = calculateIdf(token, documents);
if (inverseDocumentFrequency === Infinity) {
inverseDocumentFrequency = 0;
}
var uid;
if (uidFieldName instanceof Array) {
uid = document && getNestedFieldValue(document, uidFieldName);
} else {
uid = document && document[uidFieldName];
}
var termFrequency = typeof tokenMap[token] !== 'undefined' && typeof tokenMap[token].$uidMap[uid] !== 'undefined' ? tokenMap[token].$uidMap[uid].$numTokenOccurrences : 0;
score += termFrequency * inverseDocumentFrequency;
}
return score;
};
};
return TfIdfSearchIndex;
}();
/**
* Search index capable of returning results matching a set of tokens but without any meaningful rank or order.
*/
var UnorderedSearchIndex = /*#__PURE__*/function () {
function UnorderedSearchIndex() {
this._tokenToUidToDocumentMap = {};
}
/**
* @inheritDocs
*/
var _proto = UnorderedSearchIndex.prototype;
_proto.indexDocument = function indexDocument(token, uid, doc) {
if (typeof this._tokenToUidToDocumentMap[token] !== 'object') {
this._tokenToUidToDocumentMap[token] = {};
}
this._tokenToUidToDocumentMap[token][uid] = doc;
}
/**
* @inheritDocs
*/
;
_proto.search = function search(tokens, corpus) {
var intersectingDocumentMap = {};
var tokenToUidToDocumentMap = this._tokenToUidToDocumentMap;
for (var i = 0, numTokens = tokens.length; i < numTokens; i++) {
var token = tokens[i];
var documentMap = tokenToUidToDocumentMap[token]; // Short circuit if no matches were found for any given token.
if (!documentMap) {
return [];
}
if (i === 0) {
var keys = Object.keys(documentMap);
for (var j = 0, numKeys = keys.length; j < numKeys; j++) {
var uid = keys[j];
intersectingDocumentMap[uid] = documentMap[uid];
}
} else {
var keys = Object.keys(intersectingDocumentMap);
for (var j = 0, numKeys = keys.length; j < numKeys; j++) {
var uid = keys[j];
if (typeof documentMap[uid] !== 'object') {
delete intersectingDocumentMap[uid];
}
}
}
}
var keys = Object.keys(intersectingDocumentMap);
var documents = [];
for (var i = 0, numKeys = keys.length; i < numKeys; i++) {
var uid = keys[i];
documents.push(intersectingDocumentMap[uid]);
}
return documents;
};
return UnorderedSearchIndex;
}();
var REGEX = /[^a-zа-яё0-9\-']+/i;
/**
* Simple tokenizer that splits strings on whitespace characters and returns an array of all non-empty substrings.
*/
var SimpleTokenizer = /*#__PURE__*/function () {
function SimpleTokenizer() {}
var _proto = SimpleTokenizer.prototype;
/**
* @inheritDocs
*/
_proto.tokenize = function tokenize(text) {
return text.split(REGEX).filter(function (text) {
return text;
} // Filter empty tokens
);
};
return SimpleTokenizer;
}();
/**
* Stemming is the process of reducing search tokens to their root (or stem) so that searches for different forms of a
* word will match. For example "search", "searching" and "searched" are all reduced to the stem "search".
*
* <p>This stemming tokenizer converts tokens (words) to their stem forms before returning them. It requires an
* external stemming function to be provided; for this purpose I recommend the NPM 'porter-stemmer' library.
*
* <p>For more information see http : //tartarus.org/~martin/PorterStemmer/
*/
var StemmingTokenizer = /*#__PURE__*/function () {
/**
* Constructor.
*
* @param stemmingFunction Function capable of accepting a word and returning its stem.
* @param decoratedIndexStrategy Index strategy to be run after all stop words have been removed.
*/
function StemmingTokenizer(stemmingFunction, decoratedTokenizer) {
this._stemmingFunction = stemmingFunction;
this._tokenizer = decoratedTokenizer;
}
/**
* @inheritDocs
*/
var _proto = StemmingTokenizer.prototype;
_proto.tokenize = function tokenize(text) {
return this._tokenizer.tokenize(text).map(this._stemmingFunction);
};
return StemmingTokenizer;
}();
/**
* Stop words list copied from Lunr JS.
*/
var StopWordsMap = {
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
}; // Prevent false positives for inherited properties
StopWordsMap.constructor = false;
StopWordsMap.hasOwnProperty = false;
StopWordsMap.isPrototypeOf = false;
StopWordsMap.propertyIsEnumerable = false;
StopWordsMap.toLocaleString = false;
StopWordsMap.toString = false;
StopWordsMap.valueOf = false;
/**
* Stop words are very common (e.g. "a", "and", "the") and are often not semantically meaningful in the context of a
* search. This tokenizer removes stop words from a set of tokens before passing the remaining tokens along for
* indexing or searching purposes.
*/
var StopWordsTokenizer = /*#__PURE__*/function () {
/**
* Constructor.
*
* @param decoratedIndexStrategy Index strategy to be run after all stop words have been removed.
*/
function StopWordsTokenizer(decoratedTokenizer) {
this._tokenizer = decoratedTokenizer;
}
/**
* @inheritDocs
*/
var _proto = StopWordsTokenizer.prototype;
_proto.tokenize = function tokenize(text) {
return this._tokenizer.tokenize(text).filter(function (token) {
return !StopWordsMap[token];
});
};
return StopWordsTokenizer;
}();
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}
/**
* Simple client-side searching within a set of documents.
*
* <p>Documents can be searched by any number of fields. Indexing and search strategies are highly customizable.
*/
var Search = /*#__PURE__*/function () {
/**
* Array containing either a property name or a path (list of property names) to a nested value
*/
/**
* Constructor.
* @param uidFieldName Field containing values that uniquely identify search documents; this field's values are used
* to ensure that a search result set does not contain duplicate objects.
*/
function Search(uidFieldName) {
if (!uidFieldName) {
throw Error('js-search requires a uid field name constructor parameter');
}
this._uidFieldName = uidFieldName; // Set default/recommended strategies
this._indexStrategy = new PrefixIndexStrategy();
this._searchIndex = new TfIdfSearchIndex(uidFieldName);
this._sanitizer = new LowerCaseSanitizer();
this._tokenizer = new SimpleTokenizer();
this._documents = [];
this._searchableFields = [];
}
/**
* Override the default index strategy.
* @param value Custom index strategy
* @throws Error if documents have already been indexed by this search instance
*/
var _proto = Search.prototype;
/**
* Add a searchable document to the index. Document will automatically be indexed for search.
* @param document
*/
_proto.addDocument = function addDocument(document) {
this.addDocuments([document]);
}
/**
* Adds searchable documents to the index. Documents will automatically be indexed for search.
* @param document
*/
;
_proto.addDocuments = function addDocuments(documents) {
this._documents = this._documents.concat(documents);
this.indexDocuments_(documents, this._searchableFields);
}
/**
* Add a new searchable field to the index. Existing documents will automatically be indexed using this new field.
*
* @param field Searchable field or field path. Pass a string to index a top-level field and an array of strings for nested fields.
*/
;
_proto.addIndex = function addIndex(field) {
this._searchableFields.push(field);
this.indexDocuments_(this._documents, [field]);
}
/**
* Search all documents for ones matching the specified query text.
* @param query
* @returns {Array<Object>}
*/
;
_proto.search = function search(query) {
var tokens = this._tokenizer.tokenize(this._sanitizer.sanitize(query));
return this._searchIndex.search(tokens, this._documents);
}
/**
* @param documents
* @param _searchableFields Array containing property names and paths (lists of property names) to nested values
* @private
*/
;
_proto.indexDocuments_ = function indexDocuments_(documents, _searchableFields) {
this._initialized = true;
var indexStrategy = this._indexStrategy;
var sanitizer = this._sanitizer;
var searchIndex = this._searchIndex;
var tokenizer = this._tokenizer;
var uidFieldName = this._uidFieldName;
for (var di = 0, numDocuments = documents.length; di < numDocuments; di++) {
var doc = documents[di];
var uid;
if (uidFieldName instanceof Array) {
uid = getNestedFieldValue(doc, uidFieldName);
} else {
uid = doc[uidFieldName];
}
for (var sfi = 0, numSearchableFields = _searchableFields.length; sfi < numSearchableFields; sfi++) {
var fieldValue;
var searchableField = _searchableFields[sfi];
if (searchableField instanceof Array) {
fieldValue = getNestedFieldValue(doc, searchableField);
} else {
fieldValue = doc[searchableField];
}
if (fieldValue != null && typeof fieldValue !== 'string' && fieldValue.toString) {
fieldValue = fieldValue.toString();
}
if (typeof fieldValue === 'string') {
var fieldTokens = tokenizer.tokenize(sanitizer.sanitize(fieldValue));
for (var fti = 0, numFieldValues = fieldTokens.length; fti < numFieldValues; fti++) {
var fieldToken = fieldTokens[fti];
var expandedTokens = indexStrategy.expandToken(fieldToken);
for (var eti = 0, nummExpandedTokens = expandedTokens.length; eti < nummExpandedTokens; eti++) {
var expandedToken = expandedTokens[eti];
searchIndex.indexDocument(expandedToken, uid, doc);
}
}
}
}
}
};
_createClass(Search, [{
key: "indexStrategy",
set: function set(value) {
if (this._initialized) {
throw Error('IIndexStrategy cannot be set after initialization');
}
this._indexStrategy = value;
},
get: function get() {
return this._indexStrategy;
}
/**
* Override the default text sanitizing strategy.
* @param value Custom text sanitizing strategy
* @throws Error if documents have already been indexed by this search instance
*/
}, {
key: "sanitizer",
set: function set(value) {
if (this._initialized) {
throw Error('ISanitizer cannot be set after initialization');
}
this._sanitizer = value;
},
get: function get() {
return this._sanitizer;
}
/**
* Override the default search index strategy.
* @param value Custom search index strategy
* @throws Error if documents have already been indexed
*/
}, {
key: "searchIndex",
set: function set(value) {
if (this._initialized) {
throw Error('ISearchIndex cannot be set after initialization');
}
this._searchIndex = value;
},
get: function get() {
return this._searchIndex;
}
/**
* Override the default text tokenizing strategy.
* @param value Custom text tokenizing strategy
* @throws Error if documents have already been indexed by this search instance
*/
}, {
key: "tokenizer",
set: function set(value) {
if (this._initialized) {
throw Error('ITokenizer cannot be set after initialization');
}
this._tokenizer = value;
},
get: function get() {
return this._tokenizer;
}
}]);
return Search;
}();
/**
* This utility highlights the occurrences of tokens within a string of text. It can be used to give visual indicators
* of match criteria within searchable fields.
*
* <p>For performance purposes this highlighter only works with full-word or prefix token indexes.
*/
var TokenHighlighter = /*#__PURE__*/function () {
/**
* Constructor.
*
* @param opt_indexStrategy Index strategy used by Search
* @param opt_sanitizer Sanitizer used by Search
* @param opt_wrapperTagName Optional wrapper tag name; defaults to 'mark' (e.g. <mark>)
*/
function TokenHighlighter(opt_indexStrategy, opt_sanitizer, opt_wrapperTagName) {
this._indexStrategy = opt_indexStrategy || new PrefixIndexStrategy();
this._sanitizer = opt_sanitizer || new LowerCaseSanitizer();
this._wrapperTagName = opt_wrapperTagName || 'mark';
}
/**
* Highlights token occurrences within a string by wrapping them with a DOM element.
*
* @param text e.g. "john wayne"
* @param tokens e.g. ["wa"]
* @returns {string} e.g. "john <mark>wa</mark>yne"
*/
var _proto = TokenHighlighter.prototype;
_proto.highlight = function highlight(text, tokens) {
var tagsLength = this._wrapText('').length;
var tokenDictionary = Object.create(null); // Create a token map for easier lookup below.
for (var i = 0, numTokens = tokens.length; i < numTokens; i++) {
var token = this._sanitizer.sanitize(tokens[i]);
var expandedTokens = this._indexStrategy.expandToken(token);
for (var j = 0, numExpandedTokens = expandedTokens.length; j < numExpandedTokens; j++) {
var expandedToken = expandedTokens[j];
if (!tokenDictionary[expandedToken]) {
tokenDictionary[expandedToken] = [token];
} else {
tokenDictionary[expandedToken].push(token);
}
}
} // Track actualCurrentWord and sanitizedCurrentWord separately in case we encounter nested tags.
var actualCurrentWord = '';
var sanitizedCurrentWord = '';
var currentWordStartIndex = 0; // Note this assumes either prefix or full word matching.
for (var i = 0, textLength = text.length; i < textLength; i++) {
var character = text.charAt(i);
if (character === ' ') {
actualCurrentWord = '';
sanitizedCurrentWord = '';
currentWordStartIndex = i + 1;
} else {
actualCurrentWord += character;
sanitizedCurrentWord += this._sanitizer.sanitize(character);
}
if (tokenDictionary[sanitizedCurrentWord] && tokenDictionary[sanitizedCurrentWord].indexOf(sanitizedCurrentWord) >= 0) {
actualCurrentWord = this._wrapText(actualCurrentWord);
text = text.substring(0, currentWordStartIndex) + actualCurrentWord + text.substring(i + 1);
i += tagsLength;
textLength += tagsLength;
}
}
return text;
}
/**
* @param text to wrap
* @returns Text wrapped by wrapper tag (e.g. "foo" becomes "<mark>foo</mark>")
* @private
*/
;
_proto._wrapText = function _wrapText(text) {
var tagName = this._wrapperTagName;
return "<" + tagName + ">" + text + "</" + tagName + ">";
};
return TokenHighlighter;
}();
export { AllSubstringsIndexStrategy, CaseSensitiveSanitizer, ExactWordIndexStrategy, LowerCaseSanitizer, PrefixIndexStrategy, Search, SimpleTokenizer, StemmingTokenizer, StopWordsMap, StopWordsTokenizer, TfIdfSearchIndex, TokenHighlighter, UnorderedSearchIndex };