UNPKG

es-sifter

Version:

A library for textually searching arrays and hashes of objects by property (or multiple properties). Designed specifically for autocomplete.

381 lines (345 loc) 10.6 kB
/** * sifter.js * Copyright (c) 2013 Brian Reavis & contributors * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this * file except in compliance with the License. You may obtain a copy of the License at: * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF * ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. * * @author Brian Reavis <brian@thirdroute.com> */ /** * Textually searches arrays and hashes of objects * by property (or multiple properties). Designed * specifically for autocomplete. * * @constructor * @param {array|object} items * @param {object} items */ import {cmp, extend, escapeRegex, isArray, getattr, trim} from './utils'; import DIACRITICS from './diacritics'; export default function Sifter(items, settings) { this.items = items; this.settings = settings || {diacritics: true}; } /** * Splits a search string into an array of individual * regexps to be used to match results. * * @param {string} query * @returns {array} */ Sifter.prototype.tokenize = function(query) { query = trim(String(query || '').toLowerCase()); if (!query || !query.length) return []; var i, n, regex, letter; var tokens = []; var words = query.split(/ +/); for (i = 0, n = words.length; i < n; i++) { regex = escapeRegex(words[i]); if (this.settings.diacritics) { for (letter in DIACRITICS) { if (DIACRITICS.hasOwnProperty(letter)) { regex = regex.replace(new RegExp(letter, 'g'), DIACRITICS[letter]); } } } tokens.push({ string : words[i], regex : new RegExp(regex, 'i') }); } return tokens; }; /** * Iterates over arrays and hashes. * * ``` * this.iterator(this.items, function(item, id) { * // invoked for each item * }); * ``` * * @param {array|object} object */ Sifter.prototype.iterator = function(object, callback) { var iterator; if (isArray(object)) { iterator = Array.prototype.forEach || function(callback) { for (var i = 0, n = this.length; i < n; i++) { callback(this[i], i, this); } }; } else { iterator = function(callback) { for (var key in this) { if (this.hasOwnProperty(key)) { callback(this[key], key, this); } } }; } iterator.apply(object, [callback]); }; /** * Returns a function to be used to score individual results. * * Good matches will have a higher score than poor matches. * If an item is not a match, 0 will be returned by the function. * * @param {object|string} search * @param {object} options (optional) * @returns {function} */ Sifter.prototype.getScoreFunction = function(search, options) { var self, fields, tokens, tokenCount, nesting; self = this; search = self.prepareSearch(search, options); tokens = search.tokens; fields = search.options.fields; tokenCount = tokens.length; nesting = search.options.nesting; /** * Calculates how close of a match the * given value is against a search token. * * @param {mixed} value * @param {object} token * @return {number} */ function scoreValue(value, token) { var score, pos; if (!value) return 0; value = String(value || ''); pos = value.search(token.regex); if (pos === -1) return 0; score = token.string.length / value.length; if (pos === 0) score += 0.5; return score; } /** * Calculates the score of an object * against the search query. * * @param {object} token * @param {object} data * @return {number} */ var scoreObject = (function() { var fieldCount = fields.length; if (!fieldCount) { return function() { return 0; }; } if (fieldCount === 1) { return function(token, data) { return scoreValue(getattr(data, fields[0], nesting), token); }; } return function(token, data) { for (var i = 0, sum = 0; i < fieldCount; i++) { sum += scoreValue(getattr(data, fields[i], nesting), token); } return sum / fieldCount; }; })(); if (!tokenCount) { return function() { return 0; }; } if (tokenCount === 1) { return function(data) { return scoreObject(tokens[0], data); }; } if (search.options.conjunction === 'and') { return function(data) { var score; for (var i = 0, sum = 0; i < tokenCount; i++) { score = scoreObject(tokens[i], data); if (score <= 0) return 0; sum += score; } return sum / tokenCount; }; } else { return function(data) { for (var i = 0, sum = 0; i < tokenCount; i++) { sum += scoreObject(tokens[i], data); } return sum / tokenCount; }; } }; /** * Returns a function that can be used to compare two * results, for sorting purposes. If no sorting should * be performed, `null` will be returned. * * @param {string|object} search * @param {object} options * @return function(a,b) */ Sifter.prototype.getSortFunction = function(search, options) { var i, n, self, field, fields, fieldsCount, multiplier, multipliers, sort, implicitScore; self = this; search = self.prepareSearch(search, options); sort = (!search.query && options.sortEmpty) || options.sort; /** * Fetches the specified sort field value * from a search result item. * * @param {string} name * @param {object} result * @return {mixed} */ function getField(name, result) { if (name === '$score') return result.score; return getattr(self.items[result.id], name, options.nesting); } // parse options fields = []; if (sort) { for (i = 0, n = sort.length; i < n; i++) { if (search.query || sort[i].field !== '$score') { fields.push(sort[i]); } } } // the "$score" field is implied to be the primary // sort field, unless it's manually specified if (search.query) { implicitScore = true; for (i = 0, n = fields.length; i < n; i++) { if (fields[i].field === '$score') { implicitScore = false; break; } } if (implicitScore) { fields.unshift({field: '$score', direction: 'desc'}); } } else { for (i = 0, n = fields.length; i < n; i++) { if (fields[i].field === '$score') { fields.splice(i, 1); break; } } } multipliers = []; for (i = 0, n = fields.length; i < n; i++) { multipliers.push(fields[i].direction === 'desc' ? -1 : 1); } // build function fieldsCount = fields.length; if (!fieldsCount) { return null; } else if (fieldsCount === 1) { field = fields[0].field; multiplier = multipliers[0]; return function(a, b) { return multiplier * cmp( getField(field, a), getField(field, b) ); }; } else { return function(a, b) { var i, result, field; for (i = 0; i < fieldsCount; i++) { field = fields[i].field; result = multipliers[i] * cmp( getField(field, a), getField(field, b) ); if (result) return result; } return 0; }; } }; /** * Parses a search query and returns an object * with tokens and fields ready to be populated * with results. * * @param {string} query * @param {object} options * @returns {object} */ Sifter.prototype.prepareSearch = function(query, options) { if (typeof query === 'object') return query; options = extend({}, options); var optionFields = options.fields; var optionSort = options.sort; var optionSortEmpty = options.sortEmpty; if (optionFields && !isArray(optionFields)) options.fields = [optionFields]; if (optionSort && !isArray(optionSort)) options.sort = [optionSort]; if (optionSortEmpty && !isArray(optionSortEmpty)) options.sortEmpty = [optionSortEmpty]; return { options : options, query : String(query || '').toLowerCase(), tokens : this.tokenize(query), total : 0, items : [] }; }; /** * Searches through all items and returns a sorted array of matches. * * The `options` parameter can contain: * * - fields {string|array} * - sort {array} * - score {function} * - filter {bool} * - limit {integer} * * Returns an object containing: * * - options {object} * - query {string} * - tokens {array} * - total {int} * - items {array} * * @param {string} query * @param {object} options * @returns {object} */ Sifter.prototype.search = function(query, options) { var self = this, score, search; var fnSort; var fnScore; search = this.prepareSearch(query, options); options = search.options; query = search.query; // generate result scoring function fnScore = options.score || self.getScoreFunction(search); // perform search and sort if (query.length) { self.iterator(self.items, function(item, id) { score = fnScore(item); if (options.filter === false || score > 0) { search.items.push({'score': score, 'id': id}); } }); } else { self.iterator(self.items, function(item, id) { search.items.push({'score': 1, 'id': id}); }); } fnSort = self.getSortFunction(search, options); if (fnSort) search.items.sort(fnSort); // apply limits search.total = search.items.length; if (typeof options.limit === 'number') { search.items = search.items.slice(0, options.limit); } return search; };