(function () {
'use strict'
/**
* SearchLight
* @module search-light
*/
/** @typedef {Array|Object} Collection */
/**
* @typedef {Array} Match
* @property {*} 0 - key
* @property {number} 1 - relevance
* @property {string} 2 - missing terms
*/
/** @typedef {Match[]} Matches */
/**
* @typedef FilterObject
* @type {Object}
* @property {*} key - the property the filter applies to
* @property {string} [operator='=='] - the comparison operator to use
* @property {*} value - the value to check the items against
*/
/**
* @typedef {Array} FilterArray
* @property {*} 0 - the property the filter applies to
* @property {*} 1 - the comparison operator to use
* @property {*} [2] - the value to check the items against
* if only two elements, the second one is assumed to be the value and the operator is set to '=='
*/
/** @typedef {(string|FilterArray|FilterObject)} Constraint */
/**
* @typedef {Object} NextIterator
* @property {function} next - iterator function to use
*/
/**
* @callback SuccessCallback
* @param {Object} results
*/
/**
* @callback FailureCallback
* @param {Error} error
*/
/**
* @callback SortCallback
* @param {Function} getItem
* @param {Match} matchA
* @param {Match} matchB
*/
/**
* @class
* @memberof module:search-light
*/
function SearchLight () {}
SearchLight.prototype = {
/**
* Creates a new SearchLight instance and sets the collection
* @memberof module:search-light.SearchLight
* @static
* @param {Collection} collection - Array or object of items or properties to search
* @returns {SearchLight} instance - new SearchLight instance
* @example
* search( ['one', 'two', 'three'] )
*/
search (items) {
var sl = Object.create(SearchLight.prototype, {
/**
* State
* @private
*/
s_: {
value: {
matches: [],
partial: [],
allItems: [],
searchText: '',
searchTerms: [],
keys: [],
filters: [],
threshold: 0,
error: false,
errorMessage: '',
index: 0,
lastIndex: 0,
totalMatches: 0,
ready: false,
complete: false,
searched: false
}
},
/**
* Options
* @private
*/
o_: {
value: {
collectionType: 'array',
case: false,
baseThreshold: 0,
sort: false,
customSort: null,
inject: {
property: 'searchResults',
enabled: false
}
}
}
})
return sl.fn_.collection_.call(sl, items)
},
/**
* Set search terms or filters
* @param {Constraint} constraint - Search text or filter to replace current constraints with
* @returns {SearchLight}
* @example
* // sets the search text to be 'something'
* search(collection).for('something')
*/
for (constraint) {
this.s_.searchText = ''
this.s_.filters = []
return this.and(constraint)
},
/**
* Add additional search terms or filters
* @param {Constraint} constraint - Search text or filter to replace current constraints with
* @returns {SearchLight}
* @example
* // adds 'nothing' to the existing search text of 'something'
* search(items).for('something').and('nothing')
*/
and (constraint) {
this.s_.ready = false
this.s_.complete = false
this.s_.searched = false
if (typeof constraint === 'string') {
this.s_.searchText = (
this.s_.searchText + ' ' + constraint
).trim()
} else if (typeof constraint === 'object') {
var key, operator, value
if (Array.isArray(constraint)) {
key = constraint[0]
operator = constraint[1]
value = constraint[2]
} else {
key = constraint.key
operator = constraint.operator
value = constraint.value
}
if (typeof value === 'undefined') {
value = operator
operator = '=='
}
this.s_.filters.push([key, operator, value])
} else {
console.warn('Invalid constraint type: ', typeof constraint)
}
return this
},
/**
* Set keys to search by
* @param {(string|Array)} keys - key or array of keys
* @returns {SearchLight}
* @example
* // sets keys to be ['an_object_property']
* search(items).for('something').in('an_object_property')
*/
in (keys) {
this.s_.keys = []
return this.or(keys)
},
/**
* Add additional keys to search by
* @param {(string|Array)} keys - key or array of keys
* @returns {SearchLight}
* @example
* // adds 3 to existing array of keys ([1, 3])
* search(items).for('something').in(1).or(3)
*/
or (keys) {
this.s_.ready = false
this.s_.complete = false
this.s_.searched = false
if (typeof keys === 'string') {
this.s_.keys.push(keys)
} else {
Array.prototype.push.apply(this.s_.keys, keys)
// this.s_.keys.push(...keys)
}
return this
},
/**
* Sets the sort setting to true
* @returns {SearchLight}
*/
sorted () {
if (!this.o_.sort) {
this.o_.sort = true
this.s_.complete = false
}
return this
},
/**
* Sets the sort setting to false
* @returns {SearchLight}
*/
unsorted () {
if (this.o_.sort) {
this.o_.sort = false
this.s_.complete = false
}
return this
},
/**
* Sets a custom sort function to use on the matches
* @param {SortCallback}
* @returns {SearchLight}
*/
sortUsing (fn) {
this.o_.customSort = fn
this.s_.complete = false
return this
},
/**
* Sets the case-sensitive setting to true
* @returns {SearchLight}
*/
compareCase () {
if (!this.o_.case) {
this.o_.case = true
this.s_.complete = false
this.s_.searched = false
}
return this
},
/**
* Sets the case-sensitive setting to false
* @returns {SearchLight}
*/
ignoreCase () {
if (this.o_.case) {
this.o_.case = false
this.s_.complete = false
this.s_.searched = false
}
return this
},
/**
* Sets the inject.enabled setting to true and optionally sets the inject.property setting too
* @param {*} [property] - the property stats are injected as
* @returns {SearchLight}
*/
withStats (property) {
this.o_.inject.enabled = true
if (typeof property !== 'undefined') {
this.o_.inject.property = property
}
return this
},
/**
* Sets the inject.enabled setting to false
* @returns {SearchLight}
*/
withoutStats () {
this.o_.inject.enabled = false
return this
},
/**
* Promise support
* Allows for asynchronous processing of large collections
* @param {SuccessCallback} [success] - callback to run when/if promise completes successfully
* @param {FailureCallback} [failure] - callback to run when/if promise completes unsuccessfully
* @return {SearchLight}
* @example
* search(items).for('something')
* .then(
* function(results) { console.log(results.matches) },
* function(error) { console.log(error) }
* )
*/
then (success, failure) {
var promise = new Promise(function (resolve, reject) {
this.fn_.updateMatches_.call(this)
if (this.s_.error) {
reject(Error(this.s_.errorMessage))
} else {
var sl = this
resolve({
get matches () { return sl.matches },
get partialMatchess () { return sl.partialMatches },
get allMatchess () { return sl.allMatches }
})
}
}.bind(this))
promise.then(success, failure)
return this
},
/**
* Allows promises to be written in a more readable format
* @param {FailureCallback} failure - callback to run when/if promise completes unsuccessfully
* @returns {SearchLight}
* @example
* search(items).for('something')
* .then((results) => do_something(results.matches))
* .catch((error) => { console.log(error) })
*/
catch (failure) {
return this.then(undefined, failure)
},
/**
* Length of matches
* @returns {Number} count - total number of matches
*/
get length () {
this.fn_.updateMatches_.call(this)
return this.s_.totalMatches
},
/**
* Gets all items that match all the constraints
* @returns {Collection} items
*/
get matches () {
this.fn_.updateMatches_.call(this)
var output = this.fn_.toOriginalFormat_.call(this, this.s_.matches)
return output
},
/**
* Gets all items that only match some of the constraints
* @returns {Collection} items
*/
get partialMatches () {
this.fn_.updateMatches_.call(this)
this.fn_.performSort_.call(this, this.s_.partial)
return this.fn_.toOriginalFormat_.call(this, this.s_.partial)
},
/**
* Gets all items that match any of the constraints
* @returns {Collection} items
*/
get allMatches () {
this.fn_.updateMatches_.call(this)
var allMatches = this.s_.matches.concat(this.s_.partial)
this.fn_.performSort_.call(this, allMatches)
return this.fn_.toOriginalFormat_.call(this, allMatches)
},
/**
* Gets all items in the collection
* @returns {Collection} matches
*/
get allItems () {
this.fn_.updateMatches_.call(this)
this.fn_.performSort_.call(this, this.s_.allItems)
return this.fn_.toOriginalFormat_.call(
this,
this.s_.allItems
)
},
/**
* Implements the iterable protocol
* @memberof! module:search-light.SearchLight
* @private
* @name 'Symbol.iterator'
* @returns {NextIterator}
* @example
* // outputs 'two' and 'three' to the dev console
* for (var match in search( ['one', 'two', 'three'] ).for( 't' ) ) {
* console.log(match)
* }
*/
[Symbol.iterator] () {
this.updateMatches_()
if (this.o_.inject.enabled) {
return { next: this.i_.nextInject_.bind(this) }
} else {
return { next: this.i_.next_.bind(this) }
}
},
/**
* @private
* functions
*/
fn_: {
/**
* Set the collection to be searched
* @param {Collection} collection - Array or object of items or properties to search
* @returns {SearchLight}
* @example
* search( ['one', 'two', 'three'] )
*/
collection_ (collection) {
var keys = []
if (Array.isArray(collection)) {
keys = Array.from(collection.keys())
} else if (typeof collection === 'object') {
keys = Object.keys(collection).filter(key => collection.hasOwnProperty(key))
this.o_.collectionType = 'object'
} else {
this.s_.error = true
this.s_.errorMessage = 'Invalid collection type: ' + typeof collection
console.warn(this.s_.errorMessage)
}
this.s_.collection = new Map(
keys.map(this.i_.collection_.bind(this, collection))
)
this.s_.complete = false
this.s_.searched = false
return this
},
/**
* Sets up the search terms and calculates
* the relevance of each item in the collection
*/
performSearch_ () {
// check if there are any constraints
if (this.fn_.isConstrained.call(this)) {
this.s_.allItems = []
this.s_.matches = []
this.s_.partial = []
this.s_.collection.forEach(
this.i_.calculateRelevance_.bind(this)
)
} else {
// no constraints so the entire collection matches
this.s_.allItems = Array.from(this.s_.collection.keys()).map(
this.i_.emptyMatch_
)
this.s_.matches = this.s_.allItems
this.s_.partial = []
}
this.s_.searched = true
},
/**
* Searches and sorts items only if needed
*/
updateMatches_ () {
// do nothing if no changes to items or constraints
if (!this.s_.complete) {
if (!this.s_.searched) {
this.fn_.performSearch_.call(this)
}
this.fn_.performSort_.call(this, this.s_.matches)
this.s_.totalMatches = this.s_.matches.length
this.s_.index = 0
this.s_.lastIndex = this.s_.totalMatches - 1
this.s_.complete = true
}
},
/**
* Sorts items if needed
* @param {Matches} matches
*/
performSort_ (matches) {
if (
this.o_.sort &&
this.fn_.isConstrained.call(this) &&
matches.length
) {
matches.sort(this.i_.sortComparator_)
}
if (this.o_.customSort !== null) {
matches.sort(this.o_.customSort.bind(null, this.fn_.getItem_.bind(this)))
}
},
/**
* Gets an item from the collection
* @param {Match} match
* @returns {*} item
*/
getItem_ (match) {
return this.s_.collection.get(match[0])
},
/**
* Gets an item with stats injected
* @param {Match} match
* @returns {*} item
*/
getItemInject_ (match) {
var item = this.s_.collection.get(match[0])
if (Array.isArray(item)) {
item.push({ relevance: match[1], missing: match[2] })
} else if (typeof item === 'object') {
item[this.o_.inject.property] = {
relevance: match[1],
missing: match[2]
}
}
return item
},
/**
* Gets a subset of the collection and converts it to the same format it was added as
* @param {Matches} matches
* @returns {Collection} items
*/
toOriginalFormat_ (matches) {
if (this.o_.collectionType === 'array') {
if (this.o_.inject.enabled) {
return matches.map(this.fn_.getItemInject_, this)
} else {
return matches.map(this.fn_.getItem_, this)
}
} else {
if (this.o_.inject.enabled) {
return matches.reduce(
this.i_.reduceInject_.bind(this),
{}
)
} else {
return matches.reduce(this.i_.reduce_.bind(this), {})
}
}
},
/**
* Process and update search terms
*/
updateConstraints_ () {
if (!this.s_.ready) {
if (this.s_.searchText === '') {
this.s_.searchTerms = []
} else {
this.s_.searchTerms = (
this.o_.case
? this.s_.searchText
: this.s_.searchText.toLowerCase()
).split(' ')
}
this.s_.threshold = this.o_.baseThreshold + this.s_.filters.length + (this.s_.searchTerms.length > 0)
this.s_.ready = true
}
},
/**
* Checks if there are any constraints
* @returns {boolean} constrained
*/
isConstrained () {
// update constraints if needed
this.fn_.updateConstraints_.call(this)
return this.s_.searchTerms.length || this.s_.filters.length
}
},
/**
* @private
* iterators
*/
i_: {
/**
* Used to make an item for the collection
* @param {Collection} collection
* @param {*} key
* @returns {Array} item
*/
collection_ (collection, key) {
return [ key, collection[key] ]
},
/**
* Used to iterate through the matches
* @returns {Object} iterator
* @property {boolean} done
* @property {*} [value]
*/
next_ () {
if (this.s_.index > this.s_.lastIndex) {
return { done: true }
} else {
return {
done: false,
value: this.fn_.getItem_(this.s_.matches[ this.s_.index++ ])
}
}
},
/**
* Used to iterate through the matches and inject the stats into each match
* @returns {Object} iterator
* @property {boolean} done
* @property {*} [value]
*/
nextInject_ () {
if (this.s_.index > this.s_.lastIndex) {
return { done: true }
} else {
return {
done: false,
value: this.fn_.getItemInject_(
this.s_.matches[ this.s_.index++ ]
)
}
}
},
/**
* Used to build the searched string for each item in the collection
* @param {Array|Object} item
* @param {*} property
* @returns {*} [value]
*/
property_ (item, property) {
return item[property] || null
},
/**
* Checks each item in the collection against each search term
* @param {Match} match
* @param {string} subject
* @param {string} term
*/
search_ (match, subject, term) {
var relevance = subject.split(term).length - 1
if (relevance) {
match[1] += relevance
} else {
match[2] += ' ' + term
}
},
/**
* Checks the appropriate property of each item in the collection
* against each filter
* @param {Array|Object} item
* @param {Match} match
* @param {FilterArray} filter
*/
filter_ (item, match, filter) {
var key = filter[0]
var operator = filter[1]
var value = filter[2]
if (typeof item !== 'string') {
switch (operator) {
case '==' : match[1] += (item[ key ] == value); break // eslint-disable-line eqeqeq
case '===' : match[1] += (item[ key ] === value); break
case '!=' : match[1] += (item[ key ] != value); break // eslint-disable-line eqeqeq
case '!==' : match[1] += (item[ key ] !== value); break
case '>' : match[1] += (item[ key ] > value); break
case '>=' : match[1] += (item[ key ] >= value); break
case '<' : match[1] += (item[ key ] < value); break
case '<=' : match[1] += (item[ key ] <= value); break
case '%' : match[1] += (item[ key ].indexOf(value) !== -1); break
case '!%' : match[1] += (item[ key ].indexOf(value) === -1); break
default:
console.warn('Invalid filter operator:', operator)
}
}
},
/**
* Calculates the relevance of an item in the collection
* against the filters and search terms
* @param {*} item
* @param {*} key
*/
calculateRelevance_ (item, key) {
var match = [key, 0, '']
var subject = ''
this.s_.filters.forEach(
this.i_.filter_.bind(this, item, match)
)
if (typeof item === 'string') {
// search entire string
subject = item
} else if (this.s_.keys.length) {
// only search in given keys
subject = this.s_.keys.map(
this.i_.property_.bind(this, item)
).join('|')
} else {
// search in every enumerable property
if (Array.isArray(item)) {
subject = item.join('|')
} else {
subject = Object.keys(item).reduce(this.i_.reduceProperties_.bind(this, item), '')
}
}
if (!this.o_.case) {
subject = subject.toLowerCase()
}
this.s_.searchTerms.forEach(
this.i_.search_.bind(this, match, subject)
)
// add matches to appropriate arrays
this.s_.allItems.push(match)
if (match[1] === 0) {
// not a match
} else if (match[1] < this.s_.threshold) {
this.s_.partial.push(match) // below threshold
} else {
this.s_.matches.push(match) // above threshold
}
},
/**
* Creates an empty match
* @param {*} key
* @returns {Match} match
*/
emptyMatch_ (key) {
return [key, 0, '']
},
/**
* Reduce iterator that converts the collection to an object
* @param {Object} items - accumulator
* @param {Match} match - currentValue
* @returns {Object} items
*/
reduce_ (items, match) {
items[ match[0] ] = this.fn_.getItem_.call(this, match)
return items
},
/**
* Reduce iterator that converts the collection to an object with the
* stats for each item injected
* @param {Object} items - accumulator
* @param {Match} match - currentValue
* @returns {Object} items
*/
reduceInject_ (items, match) {
items[ match[0] ] = this.fn_.getItemInject_.call(this, match)
return items
},
/**
* Reduce iterator that creates a string of all enumerable properties of an object
* @param {Object} item - item from the collection
* @param {string} subject - accumulator
* @param {*} key - currentValue
* @returns {string} subject
*/
reduceProperties_ (item, subject, key) {
subject += ('|' + item[key])
return subject
},
/**
* Used for sorting the matches by relevance (highest relevance first)
* If two items have equal relevance, it checks the original
* insertion order to keep the sort stable
* @param {Match} matchA
* @param {Match} matchB
* @returns {number} comparison - can be -1, 0, or 1
*/
sortComparator_ (matchA, matchB) {
if (matchB[1] > matchA[1]) {
return 1
} else if (matchB[1] === matchA[1]) {
return matchB[0] < matchA[0]
} else {
return -1
}
}
}
}
if (typeof window !== 'undefined') {
window.searchLight = { search: SearchLight.prototype.search }
}
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
/**
* @function search
* @static
* @param {Collection} collection - Array or object of items or properties to search
* @returns {SearchLight} instance - new SearchLight instance
* @example
* search(['one', 'two', 'three'])
*/
module.exports.search = SearchLight.prototype.search
}
})()