UNPKG

flo

Version:

Redis powered node.js autocompleter inspired by soulmate

317 lines (278 loc) 9.42 kB
# **[flo](https://github.com/FLOChip/flo)** is an redis powered node.js autocompleter inspired by [soulmate](https://github.com/seatgeek/soulmate). # You can check out some examples [here](https://github.com/FLOChip/flo/tree/master/examples). _ = require "underscore" async = require "async" # Sets up a new Redis Connection. # # options - Optional Hash of options. # # * `redis` - An existing redis connection to use. # * `host` - String Redis host. (Default: Redis' default) # * `port` - Integer Redis port. (Default: Redis' default) # * `password` - String Redis password. # * `namespace` - String namespace prefix for Redis keys. # (Default: flo). # * `mincomplete` - Minimum completion of keys required for auto completion. # (Default: 1) # * `database` - Integer of the Redis database to select. # # Returns a Connection instance. exports.connect = (options) -> new exports.Connection options || {} # Handles the connection to the Redis server. class Connection constructor: (options) -> @helper = new Helper @redis = options.redis || connectToRedis options @namespace = options.namespace || 'flo' @mincomplete = options.mincomplete || 1 @redis.select options.database if options.database? # Public: Get all prefixes for a phrase # # * `phrase` - the phrase that needs to be parsed into many prefixes # # Returns an array of unique prefixes for the phrase prefixes_for_phrase: (phrase) -> words = @helper.normalize(phrase).split(' ') _.uniq( _.flatten( _.map(words, (w) => _.map([(@mincomplete-1)..(w.length-1)], (l) -> w[0..l] ) ) ) ) # Public: Search for a term # # * `types` - types of term that you are looking for (Array of String) # * `phrase` - the phrase or phrases you want to be autocompleted # * `limit` - the count of the number you want to return (optional, default: 5) # * `callback(err, result)` - err is the error and results is the results search_term: (types, phrase, args...) -> if typeof(args[0]) == 'number' limit = args[0] else limit = 5 callback = args[args.length-1] async.map types, (type, callb) => words = _.uniq( @helper.normalize(phrase).split(' ') ).sort() # for caching purpose cachekey = @key(type, "cache", words.join('|')) async.waterfall([ ((cb) => @redis.exists cachekey, cb ), ((exists, cb) => if !exists interkeys = _.map(words, (w) => @key(type, "index", w) ) @redis.zinterstore cachekey, interkeys.length, interkeys..., (err, count) => @redis.expire cachekey, 10 * 60, -> # expire after 10 minutes cb() else cb() ), ((cb) => @redis.zrevrange cachekey, 0, (limit - 1), (err, ids) => if ids.length > 0 @redis.hmget @key(type, "data"), ids..., cb else cb(null, []) ) ], (err, results) -> data = {} data[type] = results callb(err, data) ) , (err, results) -> results = _.extend results... results.term = phrase callback(err, results) # Public: Add a new term # # * `type` - the type of data of this term (String) # * `id` - unique identifier(within the specific type) # * `term` - the phrase you wish to provide completions for # * `score` - user specified ranking metric (redis will order things lexicographically for items with the same score) # * `data` - container for metadata that you would like to return when this item is matched (optional) # * `callback` - callback to be run (optional) # # Returns nothing. add_term: (type, id, term, score, args...) -> if typeof(args[0]) != 'function' data = args[0] callback = args[args.length-1] else if typeof(args[0]) == 'function' callback = args[0] # store the data in parallel async.parallel([ ((callb) => @redis.hset @key(type, "data"), id, JSON.stringify id: id, term: term, score: score, data: (data || []), -> callb() ), ((callb) => async.forEach @prefixes_for_phrase(term), ((w, cb) => @redis.zadd @key(type, "index", w), score, id, # sorted set -> cb() ), -> callb() ), ((callb) => key = @key(type, @helper.normalize(term)) # do we already have this term? @redis.get key, (err, result) => if (err) return callb(err) if (result) # append to existing ids (without duplicates) arr = JSON.parse(result) arr.push(id) arr = _.uniq(arr) else arr = [id] # store the id @redis.set key, JSON.stringify(arr), callb ) ], -> callback() if callback? ) # Public: Remove a term # # * `type` - the type of data of this term (String) # * `id` - unique identifier (within the specific type) # * `callback(err)` - callback to be run (optional) # # Returns nothing. remove_term: (type, id, callback) -> #get the term @redis.hget @key(type, "data"), id, (err, result) => if err return callback(err) if result == null return callback(new Error("Invalid term id")) term = JSON.parse(result).term # remove async.parallel([ ((callb) => @redis.hdel @key(type, "data"), id, callb ), ((callb) => async.forEach @prefixes_for_phrase(term), ((w, cb) => @redis.zrem @key(type, "index", w), id, -> cb() ), callb ), ((callb) => key = @key(type, @helper.normalize(term)) @redis.get key, (err, result) => if (err) return callb(err) if (result == null) return cb(new Error("Couldn't delete "+ id +". No such entry.")) arr = JSON.parse(result) if (arr.toString() == [id].toString()) # delete it return @redis.del key, callb @redis.set key, JSON.stringify(_.without(arr, id)), callb ) ], (err) -> callback(err) if callback? ) # Public: Returns an array of IDs for a term # * 'type' - the type of data for this term # * 'term' - the term to find the unique identifiers for # * 'callback(err, result)' - result is the ID for the term # Returns nothing. get_ids: (type, term, callback) -> @redis.get @key(type, @helper.normalize(term)), (err, result) -> if (err) return callback(err) arr = JSON.parse(result) if (arr == null) return callback(null, []) callback(null, arr) # Public: Returns the data for an ID # * 'type' - the type of data for this term # * `id` - unique identifier (within the specific type) # * 'callback(err, result)' - result is the data # Returns nothing. get_data: (type, id, callback) -> @redis.hget @key(type, "data"), id, (err, result) -> if err return callback(err) callback(null, JSON.parse(result)) # Public: Get the redis instance # # Returns the redis instance. redis: -> @redis # Public: Quits the connection to the Redis server. # # Returns nothing. end: -> @redis.quit() # Builds a namespaced Redis key with the given arguments. # # * `type` - Type of the param # * `args` - Array of Strings. # # Returns an assembled String key. key: (args...) -> args.unshift @namespace args.join ":" class Helper # Public: Normalize a term to remove all other characters than a-z and 0-9. # # * `term` - the term to be normalized # # Returns a normalized term. normalize: (term) -> @strip(@gsub(term.toLowerCase(), /[^a-z0-9 ]/i, '')) # Public: This function partially simulates the Ruby's String gsub method. # # * `source` - the source string # * `pattern` - the Regex pattern # * `replacement` - the replacement text # # Example: # # gsub("-abc-abc-", /[^a-z0-9 ]/i, '') # returns "abcabc" # gsub("-abc-abc-", /[^a-z0-9 ]/i, '*') # returns "*abc*abc*" # # Returns the modified string. gsub: (source, pattern, replacement) -> unless pattern? and replacement? return source result = '' while source.length > 0 if (match = source.match(pattern)) result += source.slice(0, match.index) result += replacement source = source.slice(match.index + match[0].length) else result += source source = '' result # Public: Strip out leading and trailing whitespaces. # # * `source` - string to be stripped # # Returns a copy of str with leading and trailing whitespace removed. strip: (source) -> source.replace(/^\s+/, '').replace(/\s+$/, '') connectToRedis = (options) -> redis = require('redis').createClient options.port, options.host redis.auth options.password if options.password? redis exports.Helper = new Helper exports.Connection = Connection