diff --git a/Gruntfile.js b/Gruntfile.js index 75a1938..c167ff2 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -19,7 +19,7 @@ module.exports = function(grunt) { grunt.loadNpmTasks('grunt-contrib-concat'); grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-jshint'); - + grunt.loadNpmTasks('grunt-karma'); /** Function that wraps everything to allow dynamically setting/changing grunt options and config later by grunt task. This init function is called once immediately (for using the default grunt options, config, and setup) and then may be called again AFTER updating grunt (command line) options. @toc 3. @@ -81,17 +81,17 @@ module.exports = function(grunt) { }, build: { files: {}, - src: 'r-search.js', - dest: 'r-search.min.js' + src: 'services/*.js', + dest: 'splainer-search.min.js' } - }/*, + }, karma: { unit: { - configFile: publicPathRelativeRoot+'config/karma.conf.js', + configFile: 'karma.conf.js', singleRun: true, browsers: ['PhantomJS'] } - }*/ + } }); @@ -100,9 +100,9 @@ module.exports = function(grunt) { @toc 6. */ // Default task(s). - grunt.registerTask('default', ['jshint:beforeconcatQ', 'uglify:build']); + grunt.registerTask('default', ['jshint:beforeconcatQ', 'karma:unit', 'uglify:build']); } init({}); //initialize here for defaults (init may be called again later within a task) -}; \ No newline at end of file +}; diff --git a/bower.json b/bower.json index f0edba0..dc56111 100644 --- a/bower.json +++ b/bower.json @@ -1,26 +1,26 @@ -{ - "name": "splainer-search-demo", - "version": "0.0.0", - "authors": [ - "Doug Turnbull " - ], - "description": "Demo", - "keywords": [ - ], - "license": "MIT", - "ignore": [ - "**/.*", - "node_modules", - "bower_components", - "test", - "tests" - ], - "dependencies": { - "angular":"~1.2.0", - "angular-sanitize":"~1.2.0", - "angular-route":"~1.2.0", - "angular-touch":"~1.2.0" - }, - "devDependencies": { - } -} +{ + "name": "splainer-search-demo", + "version": "0.0.0", + "authors": [ + "Doug Turnbull " + ], + "description": "Demo", + "keywords": [], + "license": "MIT", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "dependencies": { + "angular": "~1.2.0", + "angular-sanitize": "~1.2.0", + "angular-route": "~1.2.0", + "angular-touch": "~1.2.0" + }, + "devDependencies": { + "angular-mocks": "~1.2.21" + } +} diff --git a/index.html b/index.html index 3a827f7..d1e2ce7 100644 --- a/index.html +++ b/index.html @@ -14,8 +14,11 @@ - - + + + + + diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..6d3951b --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,71 @@ +// Karma configuration +// http://karma-runner.github.io/0.12/config/configuration-file.html +// Generated on 2014-07-17 using +// generator-karma 0.8.3 + +module.exports = function(config) { + 'use strict'; + + config.set({ + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // base path, that will be used to resolve files and exclude + basePath: './', + + // testing framework to use (jasmine/mocha/qunit/...) + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: [ + 'bower_components/angular/angular.js', + 'bower_components/angular-mocks/angular-mocks.js', + 'module.js', + 'services/**/*.js', + 'test/mock/**/*.js', + 'test/spec/**/*.js' + ], + + // list of files / patterns to exclude + exclude: [], + + // web server port + port: 8080, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: [ + 'PhantomJS' + ], + + // Which plugins to enable + plugins: [ + 'karma-phantomjs-launcher', + 'karma-chrome-launcher', + 'karma-jasmine' + ], + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: true, + + colors: true, + + // level of logging + // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG + logLevel: config.LOG_INFO, + + // Uncomment the following lines if you are using grunt's server to run the tests + // proxies: { + // '/': 'http://localhost:9000/' + // }, + // URL root prevent conflicts with the site root + // urlRoot: '_karma_' + }); +}; diff --git a/module.js b/module.js new file mode 100644 index 0000000..5e8756e --- /dev/null +++ b/module.js @@ -0,0 +1 @@ +angular.module('o19s.splainer-search', []); diff --git a/package.json b/package.json index 26af02f..e88e9b7 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,23 @@ -{ - "author": "", - "name": "splainer-search-demo", - "version": "0.0.0", - "description": "", - "homepage": "", - "dependencies": { - }, - "devDependencies": { - "express":"~3.4.4", - "grunt": "~0.4.1", - "grunt-contrib-concat": "~0.3.0", - "grunt-contrib-uglify": "~0.2.5", - "grunt-contrib-jshint": "~0.7.0" - }, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": "", - "engines":{ - "node":"0.10.10" - } -} +{ + "author": "", + "name": "splainer-search-demo", + "version": "0.0.0", + "description": "", + "homepage": "", + "dependencies": {}, + "devDependencies": { + "express": "~3.4.4", + "grunt": "~0.4.1", + "grunt-contrib-concat": "~0.3.0", + "grunt-contrib-jshint": "~0.7.0", + "grunt-contrib-uglify": "~0.2.5", + "grunt-karma": "^0.8.3" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": "", + "engines": { + "node": "0.10.10" + } +} diff --git a/r-search.js b/r-search.js deleted file mode 100644 index 15ef07d..0000000 --- a/r-search.js +++ /dev/null @@ -1,20 +0,0 @@ -/** -@fileOverview - -@toc - -*/ - -'use strict'; - -angular.module('o19s.splainer-search', []) -.factory('o19sRSearch', [ function () { - - //public methods & properties - var self ={ - }; - - //private methods and properties - should ONLY expose methods and properties publicly (via the 'return' object) that are supposed to be used; everything else (helper methods that aren't supposed to be called externally) should be private. - - return self; -}]); \ No newline at end of file diff --git a/services/explainSvc.js b/services/explainSvc.js new file mode 100644 index 0000000..c288517 --- /dev/null +++ b/services/explainSvc.js @@ -0,0 +1,312 @@ +'use strict'; + +// Executes a solr search and returns +// a set of queryDocs +angular.module('o19s.splainer-search') + .service('explainSvc', function explainSvc(vectorSvc) { + + var meOrOnlyChild = function(explain) { + var infl = explain.influencers(); + if (infl.length === 1) { + return infl[0]; //only child + } else { + return explain; + } + }; + + var tieRegex = /max plus ([0-9.]+) times/; + var createExplain = function(explJson) { + var base = new Explain(explJson); + var description = explJson.description; + var details = []; + if (explJson.hasOwnProperty('details')) { + details = explJson.details; + } + var tieMatch = description.match(tieRegex); + if (description.startsWith('MatchAllDocsQuery')) { + console.log('Match all docs query'); + MatchAllDocsExplain.prototype = base; + return new MatchAllDocsExplain(explJson); + } + else if (description.startsWith('weight(')) { + WeightExplain.prototype = base; + console.log('weight'); + return new WeightExplain(explJson); + } + else if (description.startsWith('FunctionQuery')) { + console.log('func query'); + FunctionQueryExplain.prototype = base; + return new FunctionQueryExplain(explJson); + } + else if (tieMatch && tieMatch.length > 1) { + console.log('dismax tie expl'); + var tie = parseFloat(tieMatch[1]); + DismaxTieExplain.prototype = base; + return new DismaxTieExplain(explJson, tie); + } + else if (description.hasSubstr('max of')) { + console.log('dismax expl'); + DismaxExplain.prototype = base; + return meOrOnlyChild(new DismaxExplain(explJson)); + } + else if (description.hasSubstr('sum of')) { + SumExplain.prototype = base; + console.log('sum or product expl'); + return meOrOnlyChild(new SumExplain(explJson)); + } + else if (description.hasSubstr('product of')) { + var coordExpl = null; + if (details.length === 2) { + angular.forEach(details, function(detail) { + if (detail.description.startsWith('coord(')) { + CoordExplain.prototype = base; + coordExpl = new CoordExplain(explJson, parseFloat(detail.value)); + } + }); + } + console.log('product expl'); + if (coordExpl !== null) { + return coordExpl; + } else { + ProductExplain.prototype = base; + return meOrOnlyChild(new ProductExplain(explJson)); + } + } + else { + console.log('regular explain'); + } + return base; + + }; + + var Explain = function(explJson) { + var datExplain = this; + this.asJson = explJson; + this.realContribution = this.score = parseFloat(explJson.value); + this.realExplanation = this.description = explJson.description; + var details = []; + if (explJson.hasOwnProperty('details')) { + details = explJson.details; + } + this.children = []; + angular.forEach(details, function(detail) { + datExplain.children.push(createExplain(detail)); + }); + + this.influencers = function() { + return []; + }; + + this.contribution = function() { + return this.realContribution; + }; + + this.explanation = function() { + return this.realExplanation; + }; + + /* Return my influencers as a vector + * where magnitude of each dimension is how + * much I am influenced + * */ + this.vectorize = function() { + var rVal = vectorSvc.create(); + rVal.set(this.explanation(), this.contribution()); + return rVal; + }; + + /* A friendly, hiererarchical view + * of all the influencers + * */ + this.toStr = function(depth) { + if (depth === undefined) { + depth = 0; + } + var prefix = new Array(2 * depth).join(' '); + var me = prefix + this.contribution() + ' ' + this.explanation() + '\n'; + var childStrs = []; + angular.forEach(this.influencers(), function(child) { + childStrs.push(child.toStr(depth+1)); + }); + return me + childStrs.join('\n'); + }; + + this.rawStr = function() { + /* global JSON */ + return JSON.stringify(this.asJson); + }; + }; + + var MatchAllDocsExplain = function() { + this.realExplanation = 'You queried *:* (all docs returned w/ score of 1)'; + }; + + var WeightExplain = function(explJson) { + // take weight(text:foo in 1234), extract text:foo + var weightRegex = /weight\((.*?)\s+in\s+\d+?\)/; + var description = explJson.description; + + var match = description.match(weightRegex); + if (match !== null && match.length > 1) { + this.realExplanation = match[1]; + } else { + this.realExplanation = description; + } + }; + + var FunctionQueryExplain = function(explJson) { + var funcQueryRegex = /FunctionQuery\((.*)\)/; + var description = explJson.description; + var match = description.match(funcQueryRegex); + if (match !== null && match.length > 1) { + this.realExplanation = match[1]; + } else { + this.realExplanation = description; + } + }; + + var CoordExplain = function(explJson, coordFactor) { + if (coordFactor < 1.0) { + this.realExplanation = 'Matches Punished by ' + coordFactor + ' (not all query terms matched)'; + + this.influencers = function() { + var infl = []; + for (var i = 0; i < this.children.length; i++) { + if (this.children[i].description.hasSubstr('coord')) { + continue; + } else { + infl.push(this.children[i]); + } + } + return infl; + }; + + this.vectorize = function() { + // scale the others by coord factor + var rVal = vectorSvc.create(); + angular.forEach(this.influencers(), function(infl) { + rVal = vectorSvc.add(rVal, infl.vectorize()); + }); + rVal = vectorSvc.scale(rVal, coordFactor); + return rVal; + }; + } + }; + + var DismaxTieExplain = function(explJson, tie) { + this.realExplanation = 'Dismax (max plus:' + tie + ' times others'; + + this.influencers = function() { + var infl = angular.copy(this.children); + infl.sort(function(a, b) {return b.score - a.score;}); + return infl; + }; + + this.vectorize = function() { + var infl = this.influencers(); + // infl[0] is the winner of the dismax competition + var rVal = infl[0].vectorize(); + angular.forEach(infl.slice(1), function(currInfl) { + var vInfl = currInfl.vectorize(); + var vInflScaled = vectorSvc.scale(vInfl, tie); + rVal = vectorSvc.add(rVal, vInflScaled); + }); + return rVal; + }; + }; + + + var DismaxExplain = function() { + this.realExplanation = 'Dismax (take winner of below)'; + + this.influencers = function() { + var infl = angular.copy(this.children); + infl.sort(function(a, b) {return b.score - a.score;}); + return infl; + }; + + this.vectorize = function() { + var infl = this.influencers(); + // Dismax, winner takes all, influencers + // are sorted by influence + return infl[0].vectorize(); + }; + }; + + var SumExplain = function() { + this.realExplanation = 'Sum of the following:'; + this.isSumExplain = true; + + this.influencers = function() { + var preInfl = angular.copy(this.children); + // Well then the child is the real influencer, we're taking sum + // of one thing + preInfl.sort(function(a, b) {return b.score - a.score;}); + var infl = []; + angular.forEach(preInfl, function(child) { + // take advantage of commutative property + if (child.hasOwnProperty('isSumExplain') && child.isSumExplain) { + angular.forEach(child.influencers(), function(grandchild) { + infl.push(grandchild); + }); + } else { + infl.push(child); + } + }); + return infl; + }; + + this.vectorize = function() { + // vector sum all the components + var rVal = vectorSvc.create(); + angular.forEach(this.influencers(), function(infl) { + rVal = vectorSvc.add(rVal, infl.vectorize()); + }); + return rVal; + }; + }; + + var ProductExplain = function() { + this.realExplanation = 'Product of following:'; + + var oneFilled = function(length) { + return Array.apply(null, new Array(length)).map(Number.prototype.valueOf,1); + }; + + this.influencers = function() { + var infl = angular.copy(this.children); + infl.sort(function(a, b) {return b.score - a.score;}); + return infl; + }; + this.vectorize = function() { + // vector sum all the components + var rVal = vectorSvc.create(); + + var infl = this.influencers(); + + var inflFactors = oneFilled(infl.length); + + for (var factorInfl = 0; factorInfl < infl.length; factorInfl++) { + for (var currMult = 0; currMult < infl.length; currMult++) { + if (currMult !== factorInfl) { + inflFactors[factorInfl] = (inflFactors[factorInfl] * infl[currMult].contribution()); + } + } + } + + for (var currInfl = 0; currInfl < infl.length; currInfl++) { + var i = infl[currInfl]; + var thisVec = i.vectorize(); + var thisScaledByOthers = vectorSvc.scale(thisVec, inflFactors[currInfl]); + rVal = vectorSvc.add(rVal, thisScaledByOthers); + } + + return rVal; + }; + }; + + this.createExplain = function(explJson) { + return createExplain(explJson); + }; + + }); diff --git a/services/fieldSpecSvc.js b/services/fieldSpecSvc.js new file mode 100644 index 0000000..feec44c --- /dev/null +++ b/services/fieldSpecSvc.js @@ -0,0 +1,87 @@ +'use strict'; + +angular.module('o19s.splainer-search') + .service('fieldSpecSvc', function fieldSpecSvc() { + // AngularJS will instantiate a singleton by calling 'new' on this function + + var addFieldOfType = function(fieldSpec, fieldType, fieldName) { + if (fieldType === 'sub') { + if (!fieldSpec.hasOwnProperty('subs')) { + fieldSpec.subs = []; + } + fieldSpec.subs.push(fieldName); + } + else if (!fieldSpec.hasOwnProperty(fieldType)) { + fieldSpec[fieldType] = fieldName; + } + fieldSpec.fields.push(fieldName); + }; + + // Populate field spec from a field spec string + var populateFieldSpec = function(fieldSpec, fieldSpecStr) { + var fieldSpecs = fieldSpecStr.split(/[\s,]+/); + angular.forEach(fieldSpecs, function(aField) { + var typeAndField = aField.split(':'); + var fieldType = null; + var fieldName = null; + if (typeAndField.length === 2) { + fieldType = typeAndField[0]; + fieldName = typeAndField[1]; + } + else if (typeAndField.length === 1) { + fieldName = typeAndField[0]; + if (fieldSpec.hasOwnProperty('title')) { + fieldType = 'sub'; + } + else { + fieldType = 'title'; + } + } + if (fieldType && fieldName) { + addFieldOfType(fieldSpec, fieldType, fieldName); + } + }); + }; + + + var FieldSpec = function(fieldSpecStr) { + this.fields = []; + this.fieldSpecStr = fieldSpecStr; + populateFieldSpec(this, fieldSpecStr); + if (!this.hasOwnProperty('id')) { + this.id = 'id'; + this.fields.push('id'); + } + + if (!this.hasOwnProperty('title')) { + this.title = this.id; + } + + this.fieldList = function() { + var rVal = [this.id]; + this.forEachField(function(fieldName) { + rVal.push(fieldName); + }); + return rVal; + }; + + // Execute innerBody for each (non id) field + this.forEachField = function(innerBody) { + if (this.hasOwnProperty('title')) { + innerBody(this.title); + } + if (this.hasOwnProperty('thumb')) { + innerBody(this.thumb); + } + angular.forEach(this.subs, function(sub) { + innerBody(sub); + }); + }; + + }; + + this.createFieldSpec = function(fieldSpecStr) { + return new FieldSpec(fieldSpecStr); + }; + + }); diff --git a/services/normalDocSvc.js b/services/normalDocSvc.js new file mode 100644 index 0000000..799b423 --- /dev/null +++ b/services/normalDocSvc.js @@ -0,0 +1,82 @@ +'use strict'; + +// Deals with normalizing documents from solr +// into a canonical representation, ie +// each doc has an id, a title, possibly a thumbnail field +// and possibly a list of sub fields +angular.module('o19s.splainer-search') + .service('normalDocsSvc', function normalDocsSvc(explainSvc) { + + var assignSingleField = function(queryDoc, solrDoc, solrField, toProperty) { + if (solrDoc.hasOwnProperty(solrField)) { + queryDoc[toProperty] = solrDoc[solrField].slice(0, 200); + } + }; + + var assignFields = function(queryDoc, solrDoc, fieldSpec) { + assignSingleField(queryDoc, solrDoc, fieldSpec.id, 'id'); + assignSingleField(queryDoc, solrDoc, fieldSpec.title, 'title'); + assignSingleField(queryDoc, solrDoc, fieldSpec.thumb, 'thumb'); + queryDoc.subs = {}; + angular.forEach(fieldSpec.subs, function(subFieldName) { + if (solrDoc.hasOwnProperty(subFieldName)) { + queryDoc.subs[subFieldName] = solrDoc[subFieldName]; + } + }); + }; + + // A document within a query + var NormalDoc = function(fieldSpec, doc) { + this.solrDoc = doc; + assignFields(this, doc, fieldSpec); + var hasThumb = false; + if (this.hasOwnProperty('thumb')) { + hasThumb = true; + } + this.subsList = []; + var that = this; + angular.forEach(this.subs, function(subValue, subField) { + if (typeof(subValue) === 'string') { + subValue = subValue.slice(0,200); + } + var expanded = {field: subField, value: subValue}; + that.subsList.push(expanded); + }); + + this.hasThumb = function() { + return hasThumb; + }; + + this.url = function() { + return this.solrDoc.url(fieldSpec.id, this.id); + }; + + var explainJson = this.solrDoc.explain(this.id); + var simplerExplain = explainSvc.createExplain(explainJson); + var explSummary = simplerExplain.toStr(); + var hotMatches = simplerExplain.vectorize().toStr(); + this.explain = function() { + return simplerExplain; + }; + this.explainSummary = function() { + return explSummary; + }; + this.hotMatches = function() { + return hotMatches; + }; + this.score = simplerExplain.contribution(); + }; + + this.createNormalDoc = function(fieldSpec, doc) { + return new NormalDoc(fieldSpec, doc); + }; + + // A stub, used to display a result that we expected + // to find in Solr, but isn't there + this.createPlaceholderDoc = function(docId, stubTitle) { + return {id: docId, + title: stubTitle}; + }; + + + }); diff --git a/services/solrSearchSvc.js b/services/solrSearchSvc.js new file mode 100644 index 0000000..2ec5ea4 --- /dev/null +++ b/services/solrSearchSvc.js @@ -0,0 +1,222 @@ +'use strict'; + +// Executes a solr search and returns +// a set of queryDocs +angular.module('o19s.splainer-search') + .service('solrSearchSvc', function solrSearchSvc($http) { + // AngularJS will instantiate a singleton by calling 'new' on this function + var activeQueries = 0; + + var buildUrl = function(url, urlArgs) { + var baseUrl = url + '?'; + angular.forEach(urlArgs, function(values, param) { + angular.forEach(values, function(value) { + baseUrl += param + '=' + value + '&'; + }); + }); + // percentages need to be escaped before + // url escaping + baseUrl = baseUrl.replace(/%/g, '%25'); + return baseUrl.slice(0, -1); // take out last & or trailing ? if no args + }; + + var searchSvc = this; + var buildTokensUrl = function(fieldList, solrUrl, idField, docId) { + var escId = encodeURIComponent(searchSvc.escapeUserQuery(docId)); + var tokensArgs = { + 'indent': ['true'], + 'wt': ['xml'], + //'q': [idField + ':' + escId], + 'facet': ['true'], + 'facet.field': [], + 'facet.mincount': ['1'], + }; + angular.forEach(fieldList, function(fieldName) { + if (fieldName !== 'score') { + tokensArgs['facet.field'].push(fieldName); + } + }); + return buildUrl(solrUrl, tokensArgs) + '&q=' + idField + ':' + escId; + }; + + var buildSolrUrl = function(fieldList, solrUrl, solrArgs, queryText) { + solrArgs.fl = [fieldList.join(' ')]; + solrArgs.wt = ['json']; + solrArgs.debug = ['true']; + solrArgs['debug.explain.structured'] = ['true']; + var baseUrl = buildUrl(solrUrl, solrArgs); + baseUrl = baseUrl.replace(/#\$query##/g, encodeURIComponent(queryText)); + return baseUrl; + }; + + var SolrSearcher = function(fieldList, solrUrl, solrArgs, queryText) { + this.callUrl = this.linkUrl = ''; + this.callUrl = buildSolrUrl(fieldList, solrUrl, solrArgs, queryText); + this.linkUrl = this.callUrl.replace('wt=json', 'wt=xml'); + this.linkUrl = this.linkUrl + '&indent=true&echoParams=all'; + this.docs = []; + this.numFound = 0; + this.inError = false; + + + this.search = function() { + var url = this.callUrl + '&json.wrf=JSON_CALLBACK'; + this.inError = false; + + var promise = Promise.create(this.search); + var that = this; + + var getExplData = function(data) { + if (data.hasOwnProperty('debug')) { + var dbg = data.debug; + if (dbg.hasOwnProperty('explain')) { + return dbg.explain; + } + } + return {}; + }; + + activeQueries++; + $http.jsonp(url).success(function(data) { + activeQueries--; + that.numFound = data.response.numFound; + var explDict = getExplData(data); + angular.forEach(data.response.docs, function(solrDoc) { + solrDoc.url = function(idField, docId) { + return buildTokensUrl(fieldList, solrUrl, idField, docId); + }; + solrDoc.explain = function(docId) { + if (explDict.hasOwnProperty(docId)) { + return explDict[docId]; + } else { + return ''; + } + }; + that.docs.push(solrDoc); + }); + promise.complete(); + }).error(function() { + activeQueries--; + that.inError = true; + promise.complete(); + }); + return promise; + + }; + }; + + this.createSearcherFromSettings = function(settings, queryText) { + return new SolrSearcher(settings.createFieldSpec().fieldList(), settings.solrUrl, + settings.selectedTry.solrArgs, queryText); + }; + + this.createSearcher = function (fieldList, solrUrl, solrArgs, queryText) { + return new SolrSearcher(fieldList, solrUrl, solrArgs, queryText); + }; + + this.activeQueries = function() { + return activeQueries; + }; + + this.escapeUserQuery = function(queryText) { + var escapeChars = ['+', '-', '&', '!', '(', ')', '[', ']', + '{', '}', '^', '"', '~', '*', '?', ':', '\\']; + var regexp = new RegExp('(\\' + escapeChars.join('|\\') + ')', 'g'); + return queryText.replace(regexp, '\\$1'); + }; + + this.parseSolrArgs = function(argsStr) { + var splitUp = argsStr.split('?'); + if (splitUp.length === 2) { + argsStr = splitUp[1]; + } + var vars = argsStr.split('&'); + var rVal = {}; + angular.forEach(vars, function(qVar) { + var nameAndValue = qVar.split('='); + if (nameAndValue.length === 2) { + var name = nameAndValue[0]; + var value = nameAndValue[1]; + var decodedValue = decodeURIComponent(value); + if (!rVal.hasOwnProperty(name)) { + rVal[name] = [decodedValue]; + } else { + rVal[name].push(decodedValue); + } + } + }); + return rVal; + }; + + /* Given arguments of the form {q: ['*:*'], fq: ['title:foo', 'text:bar']} + * turn into string suitable for URL query param q=*:*&fq=title:foo&fq=text:bar + * + * */ + this.formatSolrArgs = function(argsObj) { + var rVal = ''; + angular.forEach(argsObj, function(values, param) { + angular.forEach(values, function(value) { + rVal += param + '=' + value + '&'; + }); + }); + // percentages need to be escaped before + // url escaping + rVal = rVal.replace(/%/g, '%25'); + return rVal.slice(0, -1); // take out last & or trailing ? if no args + }; + + /* Parse a Solr URL of the form [/]solr/[collectionName]/[requestHandler] + * return object with {collectionName: , requestHandler: } + * return null on failure to parse as above solr url + * */ + this.parseSolrPath = function(pathStr) { + if (pathStr.startsWith('/')) { + pathStr = pathStr.slice(1); + } + var solrPrefix = 'solr/'; + if (pathStr.startsWith(solrPrefix)) { + pathStr = pathStr.slice(solrPrefix.length); + var colAndHandler = pathStr.split('/'); + if (colAndHandler.length === 2) { + var collectionName = colAndHandler[0]; + var requestHandler = colAndHandler[1]; + if (requestHandler.endsWith('/')) { + requestHandler = requestHandler.slice(0, requestHandler.length - 1); + } + return {'collectionName': collectionName, + 'requestHandler': requestHandler}; + } + } + return null; + }; + + /* Parse a Sor URL of the form [http|https]://[host]/solr/[collectionName]/[requestHandler]?[args] + * return null on failure to parse + * */ + this.parseSolrUrl = function(solrReq) { + + var parseUrl = function(url) { + var a = document.createElement('a'); + a.href = url; + return a; + }; + + var parsedUrl = parseUrl(solrReq); + parsedUrl.solrArgs = this.parseSolrArgs(parsedUrl.search); + var pathParsed = this.parseSolrPath(parsedUrl.pathname); + if (pathParsed) { + parsedUrl.collectionName = pathParsed.collectionName; + parsedUrl.requestHandler = pathParsed.requestHandler; + } else { + return null; + } + var solrEndpoint = function() { + return parsedUrl.protocol + '//' + parsedUrl.host + parsedUrl.pathname; + }; + + parsedUrl.solrEndpoint = solrEndpoint; + return parsedUrl; + + }; + + }); diff --git a/services/vectorSvc.js b/services/vectorSvc.js new file mode 100644 index 0000000..a058918 --- /dev/null +++ b/services/vectorSvc.js @@ -0,0 +1,63 @@ +'use strict'; + +/* + * Basic vector operations used by explain svc + * + * */ +angular.module('o19s.splainer-search') + .service('vectorSvc', function vectorSvc() { + + var SparseVector = function() { + this.vecObj = {}; + + this.set = function(key, value) { + this.vecObj[key] = value; + }; + + this.get = function(key) { + if (this.vecObj.hasOwnProperty(key)) { + return this.vecObj[key]; + } + return undefined; + }; + + this.toStr = function() { + var rVal = ''; + // sort + var sortedL = []; + angular.forEach(this.vecObj, function(value, key) { + sortedL.push([key, value]); + }); + sortedL.sort(function(lhs, rhs) {return rhs[1] - lhs[1];}); + angular.forEach(sortedL, function(keyVal) { + rVal += (keyVal[1] + ' ' + keyVal[0] + '\n'); + }); + return rVal; + }; + + }; + + this.create = function() { + return new SparseVector(); + }; + + this.add = function(lhs, rhs) { + var rVal = this.create(); + angular.forEach(lhs.vecObj, function(value, key) { + rVal.set(key, value); + }); + angular.forEach(rhs.vecObj, function(value, key) { + rVal.set(key, value); + }); + return rVal; + }; + + this.scale = function(lhs, scalar) { + var rVal = this.create(); + angular.forEach(lhs.vecObj, function(value, key) { + rVal.set(key, value * scalar); + }); + return rVal; + }; + + });