9ed3771f by Adam Heath

Generic solr search model class.

1 parent a96a4437
define(function(require) {
'use strict';
var _ = require('underscore');
var Backbone = require('backbone');
var NestedModels = require('backbone-nested-models');
//var module = require('module');
function getField(obj, key) {
return obj[key];
}
function mergeStaticProps(startPtr, endPtr, obj, fieldName) {
var result = obj;
var ptr = startPtr;
while (true) {
result = _.extend(result, _.result(ptr, fieldName));
if (ptr === endPtr) {
break;
}
ptr = ptr.__super__.constructor;
}
return result;
}
function getItemKeyAccessor(item) {
return item.get('key');
}
var SolrFacets = (function() {
var Facets = NestedModels.mixin(Backbone.Model.extend({
initialize: function(data, options) {
_.each(this.keys(), _.bind(function(facetName) {
var facet = this.get(facetName);
facet.on('item-change', function() {
this.trigger('item-change');
}, this);
}, this));
return Facets.__super__.initialize.apply(this, arguments);
},
resetSearch: function() {
_.each(this.values(), function(facet) {
facet.get('items').reset();
});
},
applyFacetResults: function(data) {
var facetCounts = getField(data, 'facet_counts');
var facetRanges = getField(facetCounts, 'facet_ranges');
var facetFields = getField(facetCounts, 'facet_fields');
var facetQueries = getField(facetCounts, 'facet_queries');
_.each(this.keys(), _.bind(function(facetName) {
var facet = this.get(facetName);
var type = facet.facetType;
var list;
var key, newItems = [];
var items = facet.get('items');
items.invoke('set', 'hidden', true);
var valueOverrides = {};
var excludeValues = {};
function recordIncludeValueIntoExclude(includeValue, key) {
excludeValues[includeValue] = true;
}
switch (type) {
case 'year':
case 'range':
list = facetRanges[facetName].counts;
break;
case 'field':
list = facetFields[facetName];
_.each(facet.bins, function(includeValues, key) {
var tag = facetName + ':' + key;
valueOverrides[key] = facetQueries[tag];
_.each(includeValues, recordIncludeValueIntoExclude);
});
break;
}
var addNewItem = _.bind(function addNewItem(key, value) {
if (valueOverrides[key] !== undefined) {
value = valueOverrides[key];
if (!value) {
return;
}
} else if (excludeValues[key]) {
return;
}
var item = items.get({id: key});
if (item) {
item.set({hidden: value === 0, value: value});
} else {
item = new Item({key: key, value: value});
item.on('change:checked', function() {
this.trigger('item-change');
facet.trigger('item-change');
}, this);
}
newItems.push(item);
}, this);
function checkOther(set, key) {
if (set.indexOf(facet.other) !== -1) {
addNewItem(key, facetRanges[facetName][key]);
}
}
checkOther(['all', 'before'], 'before');
_.each(list, function(value, index) {
if (index % 2 === 0) {
key = value;
} else if (value > 0) {
addNewItem(key, value);
}
});
switch (type) {
case 'field':
_.each(facet.bins, function(includeValues, key) {
var tag = facetName + ':' + key;
addNewItem(key, facetQueries[tag]);
});
break;
}
items.set(newItems, {remove: false});
}, this));
},
getFacetFormData: function() {
var result = {
facet: true,
'facet.missing': true,
'facet.field': [],
'facet.range': [],
'facet.query': [],
fq: [],
};
_.each(this.keys(), _.bind(function(facetName) {
var facet = this.get(facetName);
var queryField = facet.queryField ? facet.queryField : facetName;
var type = facet.facetType;
var valueFormatter;
var facetOptions = {};
switch (type) {
case 'year':
type = 'range';
valueFormatter = function(item) {
var key = item.get('key');
var fromDate, toDate;
if (key === 'before') {
return '[* TO ' + facet.rangeStart + '-1SECOND]';
}
fromDate = new Date(key);
toDate = new Date(key);
toDate.setUTCFullYear(toDate.getUTCFullYear() + 1);
return '[' + fromDate.toISOString() + ' TO ' + toDate.toISOString() + ']';
};
if (facet.other) {
facetOptions['facet.range.other'] = facet.other;
}
break;
case 'range':
valueFormatter = function(item) {
var key = parseInt(item.get('key'));
return '[' + key + ' TO ' + (key + facet.rangeGap) + ']';
};
break;
case 'field':
valueFormatter = facet.queryValue ? facet.queryValue : getItemKeyAccessor;
_.each(facet.bins, function(includeValues, key) {
var query = _.map(includeValues, function(includeValue, index) {
return queryField + ':' + includeValue;
}).join(' OR ');
result['facet.query'].push('{!key=' + facetName + ':' + key + ' tag=' + facetName + ':' + key + '}(' + query + ')');
});
break;
}
switch (type) {
case 'range':
facetOptions['facet.range.start'] = facet.rangeStart;
facetOptions['facet.range.end'] = facet.rangeEnd;
facetOptions['facet.range.gap'] = facet.rangeGap;
break;
}
var facetValues = [];
facet.get('items').each(function(item, index) {
if (!item.get('hidden') && item.get('checked')) {
facetValues.push(valueFormatter(item));
}
});
facetOptions.key = facetName;
if (facetValues.length) {
facetOptions.ex = facetName;
result.fq.push('{!tag=' + facetName + '}' + queryField + ':(' + facetValues.join(' OR ') + ')');
} else {
facetOptions.tag = facetName;
}
var facetOptionList = [];
_.each(facetOptions, function(value, key) {
if (false && key.indexOf('facet.') === 0) {
result['f.' + queryField + '.' + key] = value;
} else {
facetOptionList.push(key + '=' + value);
}
});
result['facet.' + type].push('{!' + facetOptionList.join(' ') + '}' + queryField);
}, this));
return result;
}
}));
var Sort = Facets.Sort = {
standard: function standardSort(a, b) {
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else {
return 0;
}
},
date: function dateSort(a, b) {
return Sort.standard.call(this, a, b);
},
number: function numberSort(a, b) {
return Sort.standard.call(this, parseInt(a), parseInt(b));
},
};
function wrapComparatorForAllBeforeAfter(comparator, doBefore, doAfter) {
if (!doBefore && !doAfter) {
return comparator;
}
return function beforeAfterComparator(a, b) {
if (doBefore) {
if (a === 'before') {
return -1;
} else if (b === 'before') {
return 1;
}
}
if (doAfter) {
if (a === 'after') {
return 1;
} else if (b === 'after') {
return -1;
}
}
return comparator.call(this, a, b);
};
}
var Facet = Facets.Facet = Backbone.Model.extend({
initialize: function(data, options) {
this.set('all', true);
(function(self) {
var skipCallback;
self.on('item-change', function() {
if (skipCallback === 'change:all') {
return;
}
var checkedCount = this.get('items').countBy(function(facetItem) {
return facetItem.get('checked');
}).true;
skipCallback = 'item-change';
try {
this.set('all', checkedCount === 0);
} finally {
skipCallback = null;
}
}, self);
self.on('change:all', function() {
if (skipCallback === 'item-change') {
return;
}
skipCallback = 'change:all';
try {
this.get('items').invoke('set', 'checked', false);
} finally {
skipCallback = null;
}
}, self);
})(this);
var sortKeyExtractor = options.sortKeyExtractor;
if (!sortKeyExtractor) {
sortKeyExtractor = getItemKeyAccessor;
}
var comparator = options.comparator;
if (!comparator) {
comparator = Sort.standard;
} else if (typeof comparator === 'string') {
comparator = Sort[comparator];
}
switch (options.facetType) {
case 'year':
case 'range':
comparator = wrapComparatorForAllBeforeAfter(comparator, ['all', 'before'].indexOf(options.other) !== -1, ['all', 'after'].indexOf(options.other) !== -1);
break;
}
var formatter = options.formatter;
if (!formatter) {
var labelMap = options.labelMap;
if (labelMap) {
formatter = function(item) {
var value = item.get('key');
var label = options.labelMap[value.toUpperCase()];
return label ? label : value;
};
} else {
formatter = getItemKeyAccessor;
}
}
this.formatter = formatter;
comparator = (function(comparator) {
return function facetItemValue(a, b) {
var keyA = sortKeyExtractor(a);
var keyB = sortKeyExtractor(b);
switch (this.get('orderByDirection')) {
case 'desc':
var tmp = keyA;
keyA = keyB;
keyB = tmp;
break;
}
return comparator.call(this, sortKeyExtractor(a), sortKeyExtractor(b));
};
})(comparator);
this.set('items', new ItemCollection([], {comparator: comparator}));
this.facetType = options.facetType;
this.other = options.other;
this.queryField = options.queryField;
this.queryValue = options.queryValue;
this.bins = options.bins;
switch (this.facetType) {
case 'year':
this.rangeStart = options.start;
this.rangeEnd = options.end;
this.rangeGap = '+' + (options.gap ? options.gap : 1) + 'YEAR';
break;
case 'range':
this.rangeStart = options.start;
this.rangeEnd = options.end;
this.rangeGap = options.gap;
break;
case 'field':
break;
default:
var e = new Error('Unsupported facet type: ' + this.facetType);
e.facet = this;
throw e;
}
return Facet.__super__.initialize.apply(this, arguments);
}
});
var ItemCollection = Backbone.Collection.extend({
remove: function() {
}
});
var Item = Facet.Item = Backbone.Model.extend({
idAttribute: 'key',
defaults: {
checked: false,
hidden: false,
},
_initialize: function(data, options) {
this.on('add', function(model, parent, options) {
// added to a collection
this.on('change:checked', function() {
parent.trigger('item-change');
});
}, this);
return Item.__super__.initialize.apply(this, arguments);
}
});
return Facets;
})();
var Pagination = (function() {
function stepFalse(e) {
e.preventDefault();
return false;
}
var Pagination = Backbone.Model.extend({
defaults: {
currentPage: 1,
hasNext: false,
hasNextJumpPage: false,
hasPrevious: false,
hasPreviousJumpPage: false,
nextJumpPage: undefined,
nextPage: undefined,
pages: [],
pageJump: 5,
pageSize: 10,
previousJumpPage: undefined,
previousPage: undefined,
totalCount: 0,
totalPages: 0,
},
initialize: function(data, options) {
var buildPages = function buildPages() {
var currentPage = parseInt(this.get('currentPage'));
var pageSize = parseInt(this.get('pageSize'));
var totalCount = parseInt(this.get('totalCount'));
if (!currentPage || !pageSize || !totalCount) {
return;
}
var pages = [];
var totalPages = Math.floor((totalCount + pageSize - 1) / pageSize);
function addPage(self, i) {
pages.push({
current: i === currentPage,
jump: function() {
self.set('currentPage', i);
return true;
},
number: i,
});
}
var startAt = currentPage - 4, endAt = currentPage + 5;
if (startAt < 1) {
endAt += (1 - startAt);
}
if (endAt > totalPages) {
startAt -= endAt - totalPages - 1;
endAt = totalPages + 1;
}
if (startAt < 1) {
startAt = 1;
}
if (endAt - startAt < 9) {
/* global console: false */
console.log('foo');
}
for (var i = startAt; i < endAt; i++) {
if (i > 0 && i <= totalPages) {
addPage(this, i);
}
}
var hasPrevious = currentPage > 1;
var hasNext = currentPage < totalPages;
var pageJump = this.get('pageJump');
var nextJumpPage, previousJumpPage, hasNextJump, hasPreviousJump;
if (pageJump) {
nextJumpPage = currentPage + pageJump;
previousJumpPage = currentPage - pageJump;
hasNextJump = nextJumpPage < totalPages;
hasPreviousJump = previousJumpPage > 0;
} else {
hasNextJump = false;
hasPreviousJump = false;
}
this.set({
hasNext: hasNext,
hasNextJump: hasNextJump,
hasPrevious: hasPrevious,
hasPreviousJump: hasPreviousJump,
nextJumpPage: hasNextJump ? nextJumpPage : undefined,
nextPage: hasNext ? currentPage + 1 : undefined,
pages: pages,
previousJumpPage: hasNextJump ? previousJumpPage : undefined,
previousPage: hasPrevious ? currentPage - 1 : undefined,
totalPages: totalPages,
});
};
this.on('change:pageSize change:currentPage change:totalCount', buildPages, this);
var installActions = _.bind(function installActions(eventName, nextName, hasNextName, previousName, hasPreviousName, pageCount) {
this.on(eventName, function() {
var hasNext = this.get(hasNextName);
var hasPrevious = this.get(hasPreviousName);
var next, previous;
if (hasNext) {
next = _.bind(function(e) {
e.preventDefault();
this.set('currentPage', this.get('currentPage') + pageCount);
return true;
}, this);
} else {
next = stepFalse;
}
if (hasPrevious) {
previous = _.bind(function(e) {
e.preventDefault();
this.set('currentPage', this.get('currentPage') - pageCount);
return true;
}, this);
} else {
previous = stepFalse;
}
this.set(nextName, next);
this.set(previousName, previous);
}, this);
this[nextName] = _.bind(function() {
return this.get(nextName)();
}, this);
this[previousName] = _.bind(function() {
return this.get(previousName)();
}, this);
}, this);
this.on('change:pageJump', function() {
var pageJump = this.get('pageJump');
installActions('change:hasNextJump change:hasPreviousJump', 'nextJump', 'hasNextJump', 'previousJump', 'hasPreviousJump', pageJump);
}, this);
installActions('change:hasNext change:hasPrevious', 'next', 'hasNext', 'previous', 'hasPrevious', 1);
buildPages.apply(this);
this.trigger('change:pageJump');
return Pagination.__super__.initialize.apply(this, arguments);
}
});
return Pagination;
})();
var Ordering = Backbone.Model.extend({
defaults: {
value: null,
items: new Backbone.Collection(),
},
parse: function(data) {
var result = _.clone(data);
if (result.items && !(result.items instanceof Backbone.Collection)) {
result.items = new Backbone.Collection(result.items, {parse: true});
}
return result;
},
});
var SolrSearch = Pagination.extend({
url: function url() {
return this.constructor.selectUrl;
},
defaults: function defaults() {
return _.extend(_.result(SolrSearch.__super__, 'defaults'), {
initializing: true,
initialized: false,
query: '',
results: new Backbone.Collection(),
ordering: new Ordering({items: this.constructor.orderingItems}, {parse: true}),
facets: new SolrFacets(this.constructor.facets),
});
},
initialize: function(data, options) {
console.log('SolrSearch:initialize');
options = (options || {});
this.set('options', new Backbone.Model({
faceting: !!options.faceting,
highlighting: !!options.highlighting,
}));
this._resetItems = {};
this._doSearch = _.debounce(_.bind(function() {
console.log('doSearch[b], about to call fetch');
this.fetch({
data: this.toJSON(),
merge: false,
traditional: true,
});
}, this), 250);
this._doSearch = (function(doSearch) {
return function() {
console.log('doSearch[a]', this._skipSearch);
if (!this._skipSearch) {
doSearch();
}
};
})(this._doSearch);
this.get('options').on('change:faceting change:highlighting', this._doSearch);
this.on('change:query', function() {
// this is silent, which causes the change events to not fire;
// this is ok, because the next .on will also see the change
// event on query, and resend the search
this._resetItems.currentPage = true;
this._resetItems.facets = true;
this._resetItems.ordering = true;
this._doSearch();
}, this);
this.on('change:pageSize', function() {
this._resetItems.currentPage = true;
this._doSearch();
}, this);
this.on('change:currentPage', this._doSearch);
this.get('facets').on('item-change', function() {
this._resetItems.currentPage = true;
this._doSearch();
}, this);
this.get('ordering').on('change:value', function() {
this._resetItems.currentPage = true;
this._doSearch();
}, this);
return SolrSearch.__super__.initialize.apply(this, arguments);
},
parse: function parse(data, options) {
// console.debug('SEARCH', data);
this._skipSearch = true;
try {
var resetItems = this._resetItems;
if (resetItems.currentPage) {
this.set('currentPage', 1);
}
if (resetItems.facets) {
this.get('facets').resetSearch();
}
if (resetItems.ordering) {
this.get('ordering').set('value', this.get('ordering').get('items').at(0).get('value'));
}
} finally {
delete this._skipSearch;
}
this._resetItems = {};
var facets = this.get('facets');
if (this.get('options').get('faceting')) {
facets.applyFacetResults(data);
console.log('facets', facets);
} else {
facets.resetSearch();
}
var list = [];
var highlighting = this.get('options').get('highlighting') ? data.highlighting : {};
var self = this;
_.each(data.response.docs, function(doc, index) {
var itemHighlighting = highlighting[doc.id];
if (!itemHighlighting) {
itemHighlighting = {};
}
list.push(self.searchParseDoc(doc, index, itemHighlighting));
});
return {
initializing: false,
initialized: true,
results: new Backbone.Collection(list),
facets: facets,
options: this.get('options'),
totalCount: data.response.numFound,
queryTime: (data.responseHeader.QTime / 1000).toFixed(2),
hasResults: list.length > 0,
};
},
searchParseDoc: function searchParseDoc(doc, index, itemHighlighting) {
var fieldsToParse = mergeStaticProps(this.constructor, SolrSearch, {}, 'parsedFieldMap');
var result = {};
_.each(fieldsToParse, function(value, key) {
var parsed;
if (_.isFunction(value)) {
parsed = value.call(this, doc, index, itemHighlighting);
} else if (_.isArray(value)) {
parsed = [];
_.each(value, function(item, i) {
var rawValue = doc[item.name];
if (rawValue) {
var parsedValue = item.parse.call(this, doc, index, itemHighlighting, rawValue, item.name);
if (parsedValue) {
parsed.push(parsedValue);
}
}
}, this);
} else {
parsed = doc[value];
}
result[key] = parsed;
}, this);
return result;
},
toJSON: function() {
var currentPage = this.get('currentPage');
var pageSize = this.get('pageSize');
var sort = this.get('ordering').get('value');
var query = this.get('query');
query = query.replace(/:+/g, ' ');
if (this._resetItems.currentPage) {
currentPage = 1;
}
if (this._resetItems.ordering) {
sort = this.get('ordering').get('items').at(0).get('value');
}
var constructor = this.constructor;
var queryFilter = constructor.queryFilter;
var result = {
defType: 'dismax',
qf: queryFilter ? queryFilter : 'text',
sort: sort,
//'f.sku.hl.fragmenter': 'regex',
//'f.sku.hl.mergeContiguous': 'true',
//'f.sku.hl.regex.pattern': '[a-zA-Z]+-[0-9]{4}-[0-9]{1}-[a-zA-Z]{1}-[0-9]+',
//'f.sku.hl.regex.pattern': '...-....-..-.-....',
fl: '*,score',
rows: pageSize,
start: (currentPage - 1) * pageSize,
wt: 'json'
};
result.fl = mergeStaticProps(this.constructor, SolrSearch, [], 'returnFields');
if (this.get('options').get('highlighting')) {
result.hl = true;
result['hl.fl'] = ['content', 'title'].concat(constructor.extraHighlightFields);
}
var dateBoostField = constructor.dateBoostField;
var popularityBoostField = constructor.popularityBoostField;
if (dateBoostField || popularityBoostField) {
var boostSum = [];
if (dateBoostField) {
result.dateBoost = 'recip(ms(NOW,' + dateBoostField + '),3.16e-11,1,1)';
boostSum.push('$dateBoost');
}
if (popularityBoostField) {
result.popularityBoost = 'def(' + popularityBoostField + ',0)';
boostSum.push('$popularityBoost');
}
result.qq = query;
result.q = '{!boost b=sum(' + boostSum.join(',') + ') v=$qq defType=$defType}';
} else {
result.q = query;
}
if (this.get('options').get('faceting')) {
var facetFormData = this.get('facets').getFacetFormData();
var fq = facetFormData.fq;
delete(facetFormData.fq);
result = _.extend(result, facetFormData);
if (fq.length) {
if (result.fq) {
result.fq = result.fq.concat(fq);
} else {
result.fq = fq;
}
}
}
return result;
},
}, {
cleanup: function cleanup(txt) {
if (txt) {
txt = txt.replace(/&amp;/g, '&');
txt = txt.replace(/&#39;/g, '\'');
txt = txt.replace(/[^a-zA-Z0-9 -\.,:;%<>\/'"|]/g, ' ');
}
return txt;
},
returnFields: [
'keywords',
'last_modified',
'path',
'score',
'title',
],
parsedFieldMap: {
content: function(doc, index, itemHighlighting) {
var content = itemHighlighting.content;
if (content && content.constructor === Array) {
content = content.join(' ');
}
if (!content) {
content = '';
}
return this.constructor.cleanup(content);
},
keywords: 'keywords',
lastModified: 'last_modified',
path: 'url',
score: 'score',
title: function(doc, index, itemHighlighting) {
var title;
if (itemHighlighting.title) {
title = itemHighlighting.title.join(' ');
} else {
title = doc.title[0];
}
return this.constructor.cleanup(title);
},
},
});
SolrSearch.Facets = SolrFacets;
SolrSearch.Pagination = Pagination;
return SolrSearch;
});
......