Created
August 14, 2011 21:38
-
-
Save thejh/1145342 to your computer and use it in GitHub Desktop.
My IRC bot, jhbot
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
coffee = require 'coffee-script' | |
https = require 'https' | |
npm = require 'npm' | |
Irc = require 'irc-js' | |
cradle = require 'cradle' | |
{GitHubApi} = require 'github' | |
request = require 'request' | |
gitHubApi = new GitHubApi() | |
githubIssueApi = gitHubApi.getIssueApi() | |
githubObjectApi = gitHubApi.getObjectApi() | |
githubCommitApi = gitHubApi.getCommitApi() | |
Search = require './search' | |
BASIC_AUTH_DATA = "Basic #{new Buffer('jhbot:XXXXXXXXXXX').toString 'base64'}" | |
BOTSAFE = /[a-zA-Z0-9]/ | |
NICKNAME_REGEX = /^[a-zA-Z0-9_][.a-zA-Z0-9_+-]+$/ | |
npmData = {} | |
lastNpmUpdate = 0 | |
lastNpmUpdateLocaltime = 0 | |
npmFetching = null | |
updateNpm = (cb) -> | |
return cb(npmData) if (new Date().getTime() - lastNpmUpdateLocaltime) < 1000*60*5 | |
return npmFetching.push cb if npmFetching? | |
npmFetching = [cb] | |
request { | |
uri: "http://registry.npmjs.org/-/all/since?startkey=#{lastNpmUpdate}" | |
}, (error, response, body) -> | |
npmData[key] = value for key, value of JSON.parse body | |
lastNpmUpdate = Date.parse response.headers.date | |
lastNpmUpdateLocaltime = new Date().getTime() | |
callback(npmData) for callback in npmFetching | |
npmFetching = null | |
github = | |
# description: string | |
# public: boolean | |
# files: {string: content: string} | |
postGist: (description, public, files, callback) -> | |
request { | |
uri: 'https://api.github.com/gists' | |
method: 'POST' | |
headers: | |
'Authorization': BASIC_AUTH_DATA | |
body: JSON.stringify {description, public, files} | |
}, (error, response, body) -> | |
if error | |
return callback error | |
try | |
responseJson = JSON.parse body.toString() | |
catch e | |
return callback e | |
return callback "didn't get a gist back" if not responseJson.id? | |
callback null, responseJson.id | |
getGist: (id, callback) -> | |
if not id? or not /^[0-9a-f]+$/.exec id | |
return callback 'invalid id' | |
request { | |
uri: "https://api.github.com/gists/#{id}" | |
}, (error, response, body) -> | |
if error | |
return callback error | |
try | |
responseJson = JSON.parse body.toString() | |
catch e | |
return callback e | |
if not responseJson.id? | |
console.log body.toString() | |
return callback "didn't get a gist back" | |
callback null, responseJson | |
database = new (cradle.Connection)( | |
'https://thejh.cloudant.com' | |
443 | |
auth: | |
username: 'thejh' | |
password: 'XXXXXXXXXX' | |
).database 'ircbot' | |
npmDatabase = new (cradle.Connection)( | |
'' | |
80 | |
).database 'registry' | |
MYNICK = 'jhbot' | |
npmLoaded = false | |
npm.load {}, (err) -> | |
throw err if err? | |
npmLoaded = true | |
irc = new Irc { | |
server: 'irc.freenode.net' | |
nick: MYNICK | |
flood_protection: true | |
user: { | |
username: 'jhbot' | |
realname: 'TheJHs Bot' | |
} | |
} | |
process.on 'uncaughtException', (err) -> | |
console.log err.stack||err | |
try | |
irc.privmsg 'thejh', "EXCEPTION: "+err | |
catch e | |
_cachedIssueList = null | |
_cachedIssueListUpdated = 0 | |
lastCsGist = null | |
min = (a, b) -> if a<b then a else b | |
max = (a, b) -> if a>b then a else b | |
contains = (arr, el) -> -1 isnt arr.indexOf el | |
arrayMax = (arr) -> | |
n = -1/0 | |
n = val for val in arr when val > n | |
n | |
zeropad = (num, maxnum) -> | |
padlen = (maxnum+"").length - (num+"").length | |
("0" for [0...padlen]).join('') + num | |
spacepadEnd = (str, paddedLength) -> | |
padlen = paddedLength - str.length | |
str + (" " for [0...padlen]).join('') | |
getIssueList = (cb) -> | |
# time in ms | |
time = new Date().getTime() | |
# assume five minutes fresh cache | |
if _cachedIssueList? and time-_cachedIssueListUpdated < 1000*60*5 | |
return cb null, _cachedIssueList | |
githubIssueApi.getList 'joyent', 'node', 'open', (err, issues) -> | |
unless err? | |
_cachedIssueList = issues | |
_cachedIssueListUpdated = time | |
console.log "fetched issue list in #{new Date().getTime()-time}ms" | |
cb err, issues | |
commands = | |
remember: (message, [name, value...], reply) -> | |
if value.length is 0 | |
return reply "you need to specify name and definition", error: true | |
value = value.join ' ' | |
unless BOTSAFE.exec value[0] | |
return reply "that value starts with a non-alphanumeric character, I don't want to store bot commands", error: true | |
docid = "definitions:#{name}" | |
database.get docid, (err, oldData) -> | |
savedCb = (err, doc) -> | |
reply if err? | |
console.error err | |
"something went wrong" | |
else | |
"saved definition of '#{name}'" | |
if err? | |
database.save docid, {data: value}, savedCb | |
else | |
database.save docid, oldData._rev, {data: value}, savedCb | |
git: | |
context: (message, [project, file, line], reply) -> | |
SAFE_STRING_REGEX = /^[a-zA-Z0-9_-]+$/ | |
if not line? | |
return reply "you must specify project, file and line", error: true | |
if not /^[0-9]+$/.exec line | |
return reply "line must be numeric", error: true | |
# we start from 0, humans start from 1 | |
line -= 1 | |
[user, project] = project.split '/' | |
if not SAFE_STRING_REGEX.exec user | |
return reply "that user name looks weird", error: true | |
if not SAFE_STRING_REGEX.exec project | |
return reply "that project name looks weird", error: true | |
githubCommitApi.getFileCommits user, project, 'master', file, (err, commits) -> | |
if err? or commits.length is 0 | |
return reply "error, getFileCommits() failed, are you sure that the data is correct?", error: true | |
githubObjectApi.showBlob user, project, commits[0].tree, file, (err, fileData) -> | |
if err? | |
return reply "error, showBlob() failed", error: true | |
fileData = fileData.data | |
fileLines = fileData.split '\n' | |
if line >= fileLines.length | |
return reply "that file only has #{fileLines.length} lines", error: true | |
console.log "base line #{line}" | |
fromLine = max 0, line-1 | |
toLine = min fileLines.length-1, line+1 | |
console.log "extracting lines #{fromLine}...#{toLine}" | |
showedLines = for lineData, i in fileLines.slice fromLine, toLine+1 | |
"#{zeropad(i+fromLine+1, toLine+1)} #{lineData}" | |
console.log "ME HAZ TEH BLOB! #{showedLines.length} shown (out of #{fileLines.length})" | |
for lineData in showedLines | |
reply lineData | |
issue: | |
search: (message, keywords, reply) -> | |
MAX_ISSUES_COUNT = 3 | |
if keywords.length is 0 | |
return reply "please specify at least one keyword", error: true | |
keywords = (keyword.toLowerCase() for keyword in keywords) | |
getIssueList (err, issues) -> | |
if err? | |
return reply "something went wrong", error: true | |
foundIssues = for issue in issues | |
continue unless (do -> | |
issueTitle = issue.title.toLowerCase() | |
for keyword in keywords | |
if -1 is issueTitle.indexOf keyword | |
return false | |
true | |
) | |
issue | |
secondHitIssues = for issue in issues | |
continue if -1 isnt foundIssues.indexOf issue | |
issuestr = (issue.title + " " + issue.body).toLowerCase() | |
continue unless (do -> | |
for keyword in keywords | |
if -1 is issuestr.indexOf keyword | |
return false | |
true | |
) | |
issue | |
foundIssues = foundIssues.concat secondHitIssues | |
foundIssueCount = foundIssues.length | |
foundIssues = foundIssues.slice 0, MAX_ISSUES_COUNT | |
if foundIssues.length is 0 | |
return reply "no issues found" | |
reply "found issues: #{foundIssueCount}#{[if foundIssueCount > MAX_ISSUES_COUNT then ", showing the first #{MAX_ISSUES_COUNT}"]}" | |
for {number, title} in foundIssues | |
reply "Issue: ##{number}: #{title}" | |
return | |
mem: (message, [name, substitutions...], reply) -> | |
if not name? | |
return reply "you need to specify a name", error: true | |
database.get "definitions:#{name}", (err, doc) -> | |
if err? | |
reply "i don't know what a #{name} is", error: true | |
else | |
data = doc.data | |
if substitutions? | |
for subst in substitutions | |
data = data.replace '$', subst | |
reply data | |
coffee: | |
compile: (message, code, reply) -> | |
code = code.join " " | |
try | |
compiled = coffee.compile code, bare: true | |
compiled = compiled.replace /\n/g, ' ' | |
compiled = compiled.replace /\s+/g, ' ' | |
reply compiled | |
catch e | |
reply 'compile() failed', error: true | |
compilegist: (message, [gistid], reply) -> | |
if not gistid? | |
if lastCsGist? and message.params[0] is '#coffeescript' | |
gistid = lastCsGist | |
else | |
return reply "please specify a gist by id or url", error: true | |
gistid = gistid.split('/').pop() | |
github.getGist gistid, (err, gist) -> | |
if err? | |
console.log err.stack or err | |
return reply "couldn't fetch the gist", error: true | |
coffeeFiles = {} | |
for filename, {content} of gist.files | |
coffeeFiles[filename] = content: try | |
coffee.compile content, bare: true | |
catch compileErr | |
if compileErr.stack? | |
compileErr.stack | |
else | |
compileErr+"" | |
github.postGist "COMPILED"+gist.description, gist.public, coffeeFiles, (err, id) -> | |
if err? | |
console.log err.stack or err | |
return reply "couldn't publish the gist", error: true | |
reply "https://gist.github.com/#{id}" | |
admin: | |
join: (message, [channel], reply) -> | |
if isChannel channel | |
irc.join channel | |
reply 'ok' | |
say: (message, [target, what...]) -> | |
irc.privmsg target, what.join ' ' | |
testAccountLookup: (message, [nick], reply) -> | |
getNicksAccount nick, (account) -> | |
reply "account name of #{nick} is #{account}" | |
npm: | |
owner: (message, [package], reply) -> | |
if not package? | |
return reply "package name missing", error: true | |
npm.commands.owner ['ls', package], (err, owners) -> | |
if err? | |
reply "error", error: true | |
else | |
if not owners?.length | |
msg = "admin party!" | |
else | |
msg = "owners: " + ("#{o.name} <#{o.email}>" for o in owners).join ', ' | |
reply msg | |
search: (message, keywords, reply) -> | |
NAMESLIMIT = 20 | |
if keywords.length == 0 | |
return reply "you must specify at least one keyword", error: true | |
updateNpm (npmData) -> | |
try | |
search = new Search keywords.join(' '), (results) -> | |
return reply "no results" if results.length is 0 | |
if results.length > NAMESLIMIT | |
truncated = true | |
results = results.slice 0, NAMESLIMIT | |
if results.length > 5 and isChannel message.params[0] | |
reply "packages (short format#{[if truncated then ', truncated']}): #{results.join ', '}" | |
else | |
reply "truncated list:" if truncated | |
for result in results | |
reply "package #{result}: #{npmData[result].description or '<no description>'}" | |
return | |
for keyword in search.keywords | |
ids = for id, entry of npmData | |
continue unless ( | |
entry.keywords? and contains entry.keywords, keyword | |
) or ( | |
contains entry.name, keyword | |
) or ( | |
entry.description? and contains entry.description, keyword | |
) | |
id | |
search.provideKeywordData keyword, ids | |
#do (keyword) -> | |
# npmDatabase.view 'app/search', startkey: keyword, endkey: keyword+'ZZZZZZZZ', (err, rows) -> | |
# if err? | |
# console.log err.stack||err | |
# return reply "internal error", error: true | |
# for row in rows | |
# searchResultModules[row.id] = row | |
# search.provideKeywordData keyword, (id for {id} in rows) | |
catch err | |
if err.stack? | |
console.log err.stack | |
return reply "internal error", error: true | |
else | |
return reply "error: #{err}", error: true | |
help: (message) -> | |
syntaxes = | |
"remember": "<keyword> <string>" | |
"git context": "<user>/<repo> <file> <line>" | |
"mem": "<keyword> [<placeholderReplacement1> [...]]" | |
"coffee compile": "<code>" | |
"coffee compilegist": "<url or id>" | |
"admin join": "<channel>" | |
"admin say": "<target> <what>" | |
"npm owner": "<project>" | |
descriptions = | |
"npm search": """ | |
search for stuff on npm. you can use '&', '|', parens, keywords. default op is '&'. | |
no operator precedence, just parens first. will show a maximum of 20 results, one per line. | |
in channels, if there are more than 5 results, they will be printed in short format. | |
""" | |
"remember": "store a string, $ is a placeholder" | |
"git context": "get three lines from the specified position in a file on github" | |
"mem": "print a stored string, replace placeholders with given parameters" | |
"coffee compile": "compile a given line of coffeescript" | |
"coffee compilegist": "compile the given coffee-gist into another js-gist" | |
"help": "print this help" | |
lines = [] | |
addHelp = (prefix, obj) -> | |
for subkey, value of obj | |
fullname = if prefix then "#{prefix} #{subkey}" else subkey | |
switch typeof value | |
when 'object' | |
addHelp fullname, value | |
when 'function' | |
lines.push fullname | |
addHelp null, commands | |
longestLine = 2 + arrayMax (length for {length} in lines) | |
outputLines = [] | |
for line in lines | |
syntax = syntaxes[line] | |
description = descriptions[line] | |
line += ' ' + syntax if syntax? | |
outputLines.push 'command: '+line | |
outputLines.push ' ' + descriptionLine for descriptionLine in description.split('\n') if description? | |
reply message.person.nick, line for line in outputLines | |
#time: (message, [location]) -> | |
# if not location? | |
# zone = 0 | |
# else | |
# if /^[+-]?[0-9]+$/.exec location | |
# zone = parseInt location, 10 | |
# else | |
# return reply message, "unknown zone (try +/-5)" | |
# reply message, new Date(new Date().getTime() + 1000*60*60*zone).toGMTString() | |
isChannel = (chanOrNick) -> | |
chanOrNick[0] == '#' | |
isOwner = (person) -> | |
{host} = person | |
host is 'wikipedia/TheJH' | |
reply = (do -> | |
replyQueue = [] | |
lastTime = 0 | |
WAIT_TIME = 2000 | |
doReply = (originalMessage, message) -> | |
if typeof originalMessage is 'string' | |
target = originalMessage | |
else | |
{person: {nick: senderNick}, params: [originalTarget]} = originalMessage | |
target = if isChannel originalTarget | |
originalTarget | |
else | |
senderNick | |
irc.privmsg target, message | |
lastTime = new Date().getTime() | |
canReplyHandler = -> | |
{originalMessage, message} = replyQueue.shift() | |
doReply originalMessage, message | |
if replyQueue.length > 0 | |
setTimeout canReplyHandler, WAIT_TIME | |
(originalMessage, message) -> | |
time = new Date().getTime() | |
if replyQueue.length is 0 and time - lastTime > WAIT_TIME | |
doReply originalMessage, message | |
else | |
if replyQueue.length is 0 | |
setTimeout canReplyHandler, WAIT_TIME - (time - lastTime) | |
replyQueue.push {originalMessage, message} | |
) | |
handleCommand = (message, commandParts) -> | |
obj = commands | |
i = 0 | |
if commandParts[0]?[0] is '@' | |
answerTargetNick = commandParts[0].substring 1 | |
unless NICKNAME_REGEX.exec answerTargetNick | |
return reply message, "That nick looks weird. I refuse." | |
commandParts.shift() | |
while typeof obj is 'object' | |
if i is commandParts.length | |
return | |
nextPart = commandParts[i++] | |
if nextPart is "admin" and not isOwner message.person | |
console.log i | |
return reply message, "you're not my admin" | |
if nextPart is "admin" | |
console.log "valid admin command from #{JSON.stringify message.person}" | |
if obj.hasOwnProperty(nextPart) and not {}.hasOwnProperty(nextPart) | |
obj = obj[nextPart] | |
else | |
return | |
if typeof obj is 'function' | |
obj message, commandParts.slice(i), (answer, options = {}) -> | |
reply message, [if not options.error and answerTargetNick? then "#{answerTargetNick}, "] + answer | |
autoLint = (original, nick, message) -> | |
nick = " #{nick}" if not NICKNAME_REGEX.exec nick | |
GIST_REGEX = /https:\/\/gist\.github\.com\/([0-9a-f]+)/ | |
gist_match = GIST_REGEX.exec message | |
lintWarn = (warning) -> | |
reply original, "#{nick}, #{warning}" | |
if gist_match | |
lastCsGist = gist_id = gist_match[1] | |
github.getGist gist_id, (err, gist) -> | |
return if err? | |
for filename, {content} of gist.files | |
lines = content.split "\n" | |
hasTabIndent = hasSpaceIndent = false | |
levels = [0] | |
indents = for line in lines | |
[indent, contentStart] = line.split /[^\t\s]/ | |
continue if not contentStart? | |
hasSpaceIndent = true if -1 < indent.indexOf " " | |
hasTabIndent = true if -1 < indent.indexOf "\t" | |
indentLevel = indent.length | |
lastIndentLevel = levels[levels.length-1] | |
levels.push indentLevel if indentLevel > lastIndentLevel | |
if indentLevel < lastIndentLevel | |
newLevelIndex = levels.indexOf indentLevel | |
if newLevelIndex is -1 | |
badOutdent = true | |
break | |
levels = levels.slice 0, newLevelIndex+1 | |
try | |
coffee.compile content, bare: true | |
valid_coffee = true | |
catch compileErr | |
if valid_coffee and hasTabIndent and hasSpaceIndent | |
lintWarn "you're using both spaces and tabs for indentation. "+ | |
"coffee treats one tab as one space, therefore the meaning of your code is messed up." | |
if badOutdent | |
lintWarn "you seem to have an outdent in your code that doesn't match the indents" | |
genericWarnings = (original, nick, message, channel) -> | |
nick = " #{nick}" if not NICKNAME_REGEX.exec nick | |
GIST_REGEX = /https:\/\/gist\.github\.com\/([0-9a-f]+)/ | |
gist_match = GIST_REGEX.exec message | |
warn = (warning) -> reply original, "#{nick}, #{warning}" | |
if channel == '#node.js' and message.indexOf('graceful-fs') != -1 and message.indexOf('npm') != -1 | |
nick = " #{nick}" if not NICKNAME_REGEX.exec nick | |
reply args, "#{nick}, if you have problems installing npm because of some 'graceful-fs not found' error, your node.js version is outdated." | |
if gist_match | |
github.getGist gist_match[1], (err, gist) -> | |
return if err? | |
for filename, {content} of gist.files | |
if content.indexOf('npm info using npm@0.') != -1 | |
warn "that version of npm (0.x) is ancient. update npm with `curl http://npmjs.org/install.sh | sudo sh`." | |
if content.indexOf("Error: Cannot find module 'graceful-fs'") != -1 and content.indexOf("fetching: http://registry.npmjs.org/") != -1 | |
warn "that version of nodejs is ancient, use 0.4.x or newer" | |
irc.on 'privmsg', (args) -> | |
BOTS = ['jhbot', 'v8bot', 'v8bot_', 'catbot'] | |
{person: {nick, user, host}, params: [chanOrNick, message]} = args | |
return if -1 isnt BOTS.indexOf nick | |
if chanOrNick is '#coffeescript' | |
autoLint args, nick, message | |
genericWarnings args, nick, message, chanOrNick.toLowerCase() | |
if message[0] isnt '!' and isChannel chanOrNick | |
return | |
if message[0] is '!' | |
message = message.substring 1 | |
messageparts = message.split ' ' | |
handleCommand args, messageparts | |
nickInfoListeners = {} | |
getNicksAccount = (usersNick, cb) -> | |
if not nickInfoListeners[usersNick] | |
nickInfoListeners[usersNick] = [] | |
irc.privmsg 'NickServ', "info =#{usersNick}" | |
nickInfoListeners[usersNick].push cb | |
_handleNicksAccount = (nick, account) -> | |
if nickInfoListeners[nick]? | |
for listener in nickInfoListeners[nick] | |
listener account | |
delete nickInfoListeners[nick] | |
# Information on \2TheJH\2 (account \2TheJH\2): | |
NICKSERV_USERINFO_REGEX = /^Information on \x02([^\x02]*)\x02 \(account \x02([^\x02]*)\x02\)/ | |
NICKSERV_HASNOUSER_REGEX = /^\x02=([^\x02]*)\x02 is not registered.$/ | |
irc.on 'notice', (args) -> | |
# server notices aren't interesting | |
return if not args.person? | |
{person: {nick, user, host}, params: [_, message]} = args | |
if nick == 'NickServ' | |
if 0 == message.indexOf 'You are now identified' | |
console.log 'alright, were identified, go on' | |
setTimeout (-> | |
irc.join "##{chan}" for chan in ['node.js', 'coffeescript'] | |
), 10000 | |
userinfoMatch = NICKSERV_USERINFO_REGEX.exec message | |
if userinfoMatch? | |
console.log "userinfo match" | |
_handleNicksAccount userinfoMatch[1], userinfoMatch[2] | |
hasNoUserMatch = NICKSERV_HASNOUSER_REGEX.exec message | |
if hasNoUserMatch? | |
_handleNicksAccount hasNoUserMatch[1], null | |
updateNpm (npmData) -> | |
console.log "NPM ready with #{Object.keys(npmData).length} entries" | |
irc.connect -> | |
irc.privmsg 'NickServ', 'IDENTIFY jhbot PASSWORD' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
OPERATORS = ['|', '&'] | |
contains = (arr, el) -> -1 isnt arr.indexOf el | |
isOp = (type) -> contains OPERATORS, type.type or type | |
uniq = (arr) -> | |
result = [] | |
result.push e for e in arr when not contains result, e | |
result | |
# both arrays should be uniq'd | |
common = (arr1, arr2) -> e for e in arr1 when contains arr2, e | |
join = (arr1, arr2) -> uniq arr1.concat arr2 | |
createNodes = (tokens) -> | |
nodes = tokens.concat() | |
# parse a bunch of strings, results and ops | |
# every second token is an op | |
_parse = (from, to) -> | |
subNodes = nodes.slice from, to+1 | |
throw 'internal error, createNodes:_parse:subNodes.length isn\'t odd' if subNodes.length%2 isnt 1 | |
while subNodes.length > 1 | |
operandA = subNodes.shift() | |
operator = subNodes.shift() | |
operandB = subNodes.shift() | |
result = {type: 'result', op: operator.type, operands: [operandA, operandB]} | |
subNodes.unshift result | |
throw 'internal error, createNodes:_parse:subNodes.length isn\'t 1 at the end' if subNodes.length isnt 1 | |
subNodes[0] | |
openPositions = [] | |
i = 0 | |
while i < nodes.length | |
node = nodes[i] | |
switch node.type | |
when 'parenOpen' | |
openPositions.push i | |
when 'parenClose' | |
openI = openPositions.pop() | |
throw 'closing paren without opening paren' if not openI? | |
newNode = _parse openI+1, i-1 | |
nodes.splice openI, i-openI+1, newNode | |
i = openI | |
i++ | |
throw 'unclosed parens' if openPositions.length isnt 0 | |
_parse 0, nodes.length-1 | |
postprocessTokens = (tokens) -> | |
throw 'first and last token may not be ops' if isOp(tokens[0]) or isOp(tokens[tokens.length-1]) | |
i = 0 | |
lastType = null | |
while i < tokens.length | |
{type} = tokens[i] | |
if -1 isnt ['parenClose', 'string'].indexOf(lastType) and -1 isnt ['parenOpen', 'string'].indexOf(type) | |
tokens.splice i, 0, type: type = 'and' | |
if -1 isnt ['|', '&'].indexOf(lastType) and -1 isnt ['|', '&'].indexOf(type) | |
throw "you can't do &| or && or whatever" | |
lastType = type | |
i++ | |
tokens | |
tokenizeSearchString = (str) -> | |
if not /^[()0-9a-zA-Z_&| -]+$/.exec str | |
throw 'invalid character' | |
tokens = [] | |
i = 0 | |
while i < str.length | |
char = str[i] | |
switch char | |
when '(' | |
tokens.push type: 'parenOpen' | |
i++ | |
when ')' | |
tokens.push type: 'parenClose' | |
i++ | |
when '|' | |
tokens.push type: 'or' | |
i++ | |
when '&' | |
tokens.push type: 'and' | |
i++ | |
when ' ' | |
i++ | |
else | |
# string | |
fromI = i | |
i++ while i < str.length and -1 is [' ', '|', '&', '(', ')'].indexOf str[i] | |
tokens.push type: 'string', value: str.slice fromI, i | |
postprocessTokens tokens | |
performFilter = (node, keywordResults) -> | |
switch node.type | |
when 'string' | |
keywordResults[node.value] | |
when 'result' | |
switch node.op | |
when 'or' | |
join( | |
performFilter node.operands[0], keywordResults | |
performFilter node.operands[1], keywordResults | |
) | |
when 'and' | |
common( | |
performFilter node.operands[0], keywordResults | |
performFilter node.operands[1], keywordResults | |
) | |
getKeywords = (tokens) -> uniq (t.value for t in tokens when t.type is 'string').sort() | |
module.exports = class Search | |
# use with try/catch! | |
constructor: (string, @callback) -> | |
tokens = tokenizeSearchString string | |
@_nodes = createNodes tokens | |
@keywords = getKeywords tokens | |
@_keywordResults = {} | |
@_keywordResultsNeeded = @keywords.length | |
provideKeywordData: (keyword, data) -> | |
throw new Error 'duplicate keyword data' if @_keywordResults[keyword]? | |
throw new Error 'unknown keyword' unless contains @keywords, keyword | |
@_keywordResults[keyword] = data | |
@_keywordResultsNeeded-- | |
if @_keywordResultsNeeded is 0 | |
@callback uniq performFilter @_nodes, @_keywordResults | |
#try | |
# console.error JSON.stringify tokens = tokenizeSearchString 'sax halfstreamxml' | |
# console.error JSON.stringify createNodes tokens | |
#catch err | |
# console.log err.stack||err |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment