diff --git a/README.md b/README.md index e64f10c..02fbb51 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It appears in the drawer. Either as a single item or with other items, such as [resources](https://github.com/adaptlearning/adapt-contrib-resources). -##Installation +## Installation ``adapt install search`` @@ -25,6 +25,8 @@ The attributes listed below are used in *course.json* to configure **Search**, a **description** (string): Extension description, defaults to "Type in search words". +**_drawerOrder** (number): Determines the order in which this extension appears as a drawer item. Acceptable values are numbers. + **noResultsMessage**: Text displayed when no results are found, defaults to "Sorry, no results were found". **awaitingResultsMessage**: Message between having enough characters to search and having search results (More than 2 characters per word). Defaults to "Formulating results...". @@ -55,7 +57,7 @@ No known limitations. ---------------------------- -**Version number:** 2.0 adapt learning logo +**Version number:** 2.2.3 adapt learning logo **Framework versions:** 2.0 **Author / maintainer:** Kineo and community with [contributors](https://github.com/cgkineo/adapt-search/graphs/contributors) **Accessibility support:** WAI AA diff --git a/bower.json b/bower.json index 2c3657a..3f1e5c9 100644 --- a/bower.json +++ b/bower.json @@ -1,8 +1,8 @@ { "name": "adapt-search", "repository": "git://github.com/cgkineo/adapt-search", - "framework": "^2.0.0", - "version": "2.1.0", + "framework": ">=2.2", + "version": "2.3.1", "homepage": "https://github.com/cgkineo/adapt-search", "issues": "https://github.com/cgkineo/adapt-search/issues/", "displayName": "Search", @@ -15,4 +15,4 @@ ], "license": "GPLv3", "targetAttribute": "_search" -} \ No newline at end of file +} diff --git a/example.json b/example.json index 191deed..1bba248 100644 --- a/example.json +++ b/example.json @@ -14,6 +14,7 @@ // in course.json "_search": { "_isEnabled": true, + "_drawerOrder": 0, "title": "", "description": "Type in search words and enter", "noResultsMessage": "Sorry, no results were found", @@ -98,6 +99,13 @@ "that","the","to","was","were","will","wish","", ], + "_matchOn": { + "_contentWordBeginsPhraseWord": false, + "_contentWordContainsPhraseWord": false, + "_contentWordEqualsPhraseWord": true , + "_phraseWordBeginsContentWord": true + }, + "_scoreQualificationThreshold": 20, "_minimumWordLength": 2, "_frequencyImportance": 5 diff --git a/js/adapt-search.js b/js/adapt-search.js index 3ea9d02..785d5b6 100644 --- a/js/adapt-search.js +++ b/js/adapt-search.js @@ -36,7 +36,8 @@ define([ var drawerObject = { title: searchConfig.title, description: searchConfig.description, - className: 'search-drawer' + className: 'search-drawer', + drawerOrder: searchConfig._drawerOrder || 0 }; Adapt.drawer.addItem(drawerObject, 'resources:showSearch'); diff --git a/js/search-algorithm.js b/js/search-algorithm.js index af96b38..9475bc5 100644 --- a/js/search-algorithm.js +++ b/js/search-algorithm.js @@ -4,549 +4,983 @@ * Maintainers - Oliver Foster , Tom Greenfield */ -define([ - "coreJS/adapt", - "coreModels/adaptModel" -], function(Adapt, AdaptModel) { - - if (!AdaptModel.prototype.getParents) { - - AdaptModel.prototype.getParents = function(shouldIncludeChild) { - var parents = []; - var context = this; - - if (shouldIncludeChild) parents.push(context); - - while (context.has("_parentId")) { - context = context.getParent(); - parents.push(context); - } - - return parents.length ? new Backbone.Collection(parents) : null; - } - - } - - var searchDefaults = { //override in course.json "_search": {} - - _searchAttributes: [ - { - "_attributeName": "_search", - "_level": 1, - "_allowTextPreview": false - }, - { - "_attributeName": "_keywords", - "_level": 1, - "_allowTextPreview": false - }, - { - "_attributeName": "keywords", - "_level": 1, - "_allowTextPreview": false - }, - { - "_attributeName": "displayTitle", - "_level": 2, - "_allowTextPreview": true - }, - { - "_attributeName": "title", - "_level": 2, - "_allowTextPreview": false - }, - { - "_attributeName": "body", - "_level": 3, - "_allowTextPreview": true - }, - { - "_attributeName": "alt", - "_level": 4, - "_allowTextPreview": false - }, - { - "_attributeName": "_alt", - "_level": 4, - "_allowTextPreview": false - }, - { - "_attributeName": "_items", - "_level": 5, - "_allowTextPreview": false - }, - { - "_attributeName": "items", - "_level": 5, - "_allowTextPreview": false - }, - { - "_attributeName": "text", - "_level": 5, - "_allowTextPreview": true - } - ], - - _hideComponents: [ - "blank" - ], - - _hideTypes: [ - - ], - - _ignoreWords: [ - "a","an","and","are","as","at","be","by","for", - "from","has","he","in","is","it","its","of","on", - "that","the","to","was","were","will","wish","", - ], - - _scoreQualificationThreshold: 20, - _minimumWordLength: 2, - _frequencyImportance: 5 - - }; - - var wordCharacters = "\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC"; - - var search = _.extend({ - - model: null, - - _searchableModels: null, - _wordIndex: null, - - _regularExpressions: { - wordCharacters: wordCharacters, - matchNotWordBoundaries: new RegExp("["+wordCharacters+"]+", "g"), - trimReplaceNonWordCharacters: new RegExp("^([^"+wordCharacters+"])+|([^"+wordCharacters+"])+$","g"), - trimReplaceWhitespace: /^\s+|\s+$/g, - escapeRegExp: function (str) { - return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); - } - }, - - initialize: function() { - this.setupListeners(); - }, - - setupListeners: function() { - this.listenToOnce(Adapt, "app:dataReady", this.onDataReady); - }, - - onDataReady: function() { - var config = Adapt.course.get("_search"); - if (!config || config._isEnabled === false) return; - - this.setupConfig(); - this._searchableModels = this.collectModelTexts(); - this.makeModelTextProfiles(); - this.indexTextProfiles(); - Adapt.trigger("search-algorithm:ready"); - }, - - setupConfig: function() { - var model = Adapt.course.get("_search") || {}; - //make sure defaults are injected, but original model reference is maintained - var modelWithDefaults = _.extend(searchDefaults, model); - - Adapt.course.set("_search", modelWithDefaults); - - this.model = new Backbone.Model(modelWithDefaults); - - }, - - collectModelTexts: function() { - - var searchAttributes = this.model.get("_searchAttributes"); - var hideComponents = this.model.get("_hideComponents"); - var hideTypes = this.model.get("_hideTypes"); - var regularExpressions = this._regularExpressions; - - - function combineAdaptModels() { - var rtn = [] - .concat(Adapt.contentObjects.models) - .concat(Adapt.articles.models) - .concat(Adapt.blocks.models) - .concat(Adapt.components.models); - - var filtered = _.filter(rtn, function(model) { - var type = model.get("_type"); - if (_.contains(hideTypes, component)) return false; - - if (type == "component") { - var component = model.get("_component"); - if (_.contains(hideComponents, component)) return false; - } - - var displayTitle = model.get("displayTitle").replace(regularExpressions.trimReplaceWhitespace, ""); - var title = model.get("title").replace(regularExpressions.trimReplaceWhitespace, ""); - - if (!displayTitle && !title) return false; - - return true; - }); - - return filtered; - } - - function getSearchableModels() { - var adaptModels = combineAdaptModels(); - - var searchable = []; - for (var i = 0, l = adaptModels.length; i < l; i++) { - var model = adaptModels[i]; - if (!isModelSearchable(model)) continue; - var json = model.toJSON(); - var searchProfile = { - "_raw": recursivelyCollectModelTexts(json) - }; - model.set("_searchProfile", searchProfile) - searchable.push(model); - } - - return new Backbone.Collection(searchable); - } - - function isModelSearchable(model) { - var trail = model.getParents(true); - var config = model.get("_search"); - if (config && config._isEnabled === false) return false; - - var firstDisabledTrailItem = trail.find(function(item) { - var config = item.get("_search"); - if (!config) return false; - if (config && config._isEnabled !== false) return false; - return true; - }); - return firstDisabledTrailItem === undefined; - } - - function recursivelyCollectModelTexts(json, level) { - var texts = []; - for (var i = 0, l = searchAttributes.length; i < l; i++) { - var attributeObject = searchAttributes[i]; - if (!json[attributeObject._attributeName]) continue; - switch (typeof json[attributeObject._attributeName]) { - case "object": - if (json[attributeObject._attributeName] instanceof Array) { - for (var sa = 0, sal = json[attributeObject._attributeName].length; sa < sal; sa++) { - switch (typeof json[attributeObject._attributeName][sa]) { - case "object": - texts = texts.concat(recursivelyCollectModelTexts(json[attributeObject._attributeName][sa], attributeObject._level)); - break; - case "string": - addString(json[attributeObject._attributeName][sa], attributeObject._level, attributeObject); - } - } - } else { - texts = texts.concat(recursivelyCollectModelTexts(json[attributeObject._attributeName], attributeObject._level)); - } - break; - case "string": - addString(json[attributeObject._attributeName], level, attributeObject); - break; - } - } - return texts; - - function addString(string, level, attributeObject) { - var textLevel = level || attributeObject._level; - var text = $("
"+string.replace(regularExpressions.trimReplaceWhitespace, "")+"
").text(); - text = text.replace(regularExpressions.trimReplaceWhitespace,""); - if (!text) return; - texts.push({ - score: 1/textLevel, - text: text, - searchAttribute: attributeObject - }); - } - } - - return getSearchableModels(); - - }, - - makeModelTextProfiles: function() { - // Handle _ignoreWords as a special case to support the authoring tool - var ignoreWords = this.model.get('_ignoreWords') instanceof Array - ? this.model.get("_ignoreWords") - : this.model.get("_ignoreWords").split(','); - - var regularExpressions = this._regularExpressions; - var searchAttributes = this.model.get("_searchAttributes"); - var minimumWordLength = this.model.get("_minimumWordLength"); - - var scores = _.uniq(_.pluck(searchAttributes, "_level")); - scores = _.map(scores, function(l) { return 1/l; }); - - for (var i = 0, l = this._searchableModels.models.length; i < l; i++) { - var item = this._searchableModels.models[i]; - var profile = item.get("_searchProfile"); - makeModelPhraseProfile(profile); - makeModelPhraseWordAndWordProfile(profile); - } - - function makeModelPhraseProfile(profile) { - profile._phrases = []; - var phrases = _.groupBy(profile._raw, function(phrase) { - return phrase.text; - }); - for (var p in phrases) { - var bestItem = _.max(phrases[p], function(item) { - return item.score; - }); - profile._phrases.push({ - searchAttribute: bestItem.searchAttribute, - phrase: bestItem.text, - score: bestItem.score, - words: [] - }); - } - return profile; - } - - function makeModelPhraseWordAndWordProfile(profile) { - profile._words = []; - for (var l = 0, ll = scores.length; l < ll; l++) { - var score = scores[l]; - var phrases = _.where(profile._phrases, { score: score }); - var scoreWords = []; - for (var i = 0, pl = phrases.length; i < pl; i++) { - var phraseObject = phrases[i]; - var chunks = phraseObject.phrase.match(regularExpressions.matchNotWordBoundaries); - var words = _.map(chunks, function(chunk) { - return chunk.replace(regularExpressions.trimReplaceNonWordCharacters, ""); - }); - phraseObject.words = _.countBy(words, function (word) { return word.toLowerCase(); }); - phraseObject.words = _.omit(phraseObject.words, ignoreWords); - scoreWords = scoreWords.concat(words); - } - scoreWords = _.filter(scoreWords, function(word) { return word.length >= minimumWordLength; }); - scoreWords = _.countBy(scoreWords, function (word) { return word.toLowerCase(); }); - scoreWords = _.omit(scoreWords, ignoreWords); - for (var word in scoreWords) { - profile._words.push({ - word: word, - score: score, - count: scoreWords[word] - }); - } - } - return profile; - } - - }, - - indexTextProfiles: function() { - - this._wordIndex = {}; - - for (var i = 0, il = this._searchableModels.models.length; i < il; i++) { - var item = this._searchableModels.models[i]; - var id = item.get("_id"); - var searchProfile = item.get("_searchProfile"); - - for (var w = 0, wl = searchProfile._words.length; w < wl; w++) { - var word = searchProfile._words[w].word; - if (Object.prototype.hasOwnProperty && !this._wordIndex.hasOwnProperty(word)) { - this._wordIndex[word] = []; - } else { - this._wordIndex[word] = this._wordIndex[word] || []; - } - this._wordIndex[word].push(id); - } - - } - - for (var word in this._wordIndex) { - this._wordIndex[word] = _.uniq(this._wordIndex[word]); - } - - }, - - find: function(findPhrase) { - - /* - returns [ - { - score: (float)score = bestPhraseAttributeScore * (wordOccurencesInSection * frequencyMultiplier) - model: model, - foundWords: { - "foundWord": (int)occurencesInSection - }, - foundPhrases: [ - { - "score": (float)score = (1/attributeLevel) - "phrase": "Test phrase", - "words": { - "Test": (int)occurencesInPhrase, - "phrase": (int)occurencesInPhrase - } - } - ] - } - ]; - */ - - var regularExpressions = this._regularExpressions; - var wordIndex = this._wordIndex; - // Handle _ignoreWords as a special case to support the authoring tool - var ignoreWords = this.model.get('_ignoreWords') instanceof Array - ? this.model.get("_ignoreWords") - : this.model.get("_ignoreWords").split(','); - var scoreQualificationThreshold = this.model.get("_scoreQualificationThreshold"); - var minimumWordLength = this.model.get("_minimumWordLength"); - var frequencyImportance = this.model.get("_frequencyImportance"); - - var json = this._searchableModels.toJSON(); - - function getFindPhraseWords(findPhrase) { - var findChunks = findPhrase.match(regularExpressions.matchNotWordBoundaries); - var findWords = _.map(findChunks, function(chunk) { - return chunk.replace(regularExpressions.trimReplaceNonWordCharacters,""); - }); - findWords = _.countBy(findWords, function(word){ return word.toLowerCase(); }); - findWords = _.omit(findWords, ignoreWords); - findWords = _.omit(findWords, function(count, item) { - return item.length < minimumWordLength; - }); - return findWords; - } - - function getMatchingIdScoreObjects(findWords) { - var matchingIdScoreObjects = {}; - - for (var findWord in findWords) { - for (var indexWord in wordIndex) { - //allow only start matches on findWord beginning with indexWord i.e. find: oneness begins with index: one - var rIndexWord = new RegExp("^"+regularExpressions.escapeRegExp(indexWord),"g"); - //allow all matches on indexWord containing findWord i.e. index: someone contains find: one, index: anti-money contains find: money - var rFindWord = new RegExp(regularExpressions.escapeRegExp(findWord),"g"); - - var isIndexMatch = rIndexWord.test(findWord); - var isFindMatch = rFindWord.test(indexWord); - - var isFullMatch = findWord == indexWord; - var isPartMatch = isIndexMatch || isFindMatch; - - if (!isFullMatch && !isPartMatch) continue; - - var partMatchRatio = 1; - if (isPartMatch && !isFullMatch) { - if (findWord.length > indexWord.length) partMatchRatio = indexWord.length/findWord.length; - else partMatchRatio = findWord.length / indexWord.length - } - - updateIdScoreObjectsForWord(matchingIdScoreObjects, indexWord, isFullMatch, partMatchRatio); - - } - } - return _.values(matchingIdScoreObjects); - } - - function updateIdScoreObjectsForWord(matchingIdScoreObjects, word, isFullMatch, partMatchRatio) { - - for (var i = 0, l = wordIndex[word].length; i < l; i++) { - var id = wordIndex[word][i]; - var model = _.findWhere(json, {_id: id}); - - if (matchingIdScoreObjects[id] === undefined) { - matchingIdScoreObjects[id] = { - score: 0, - foundWords: {}, - foundPhrases: null, - model: Adapt.findById(id) - }; - } - - if (matchingIdScoreObjects[id].foundWords[word] === undefined) matchingIdScoreObjects[id].foundWords[word] = 0; - - var allPhraseWordRatingObjects = _.where(model._searchProfile._words, { word: word }); - var wordFrequency = _.reduce(allPhraseWordRatingObjects, function(memo, item) { return memo + item.count; }, 0); - var wordFrequencyHitScore = _.reduce(allPhraseWordRatingObjects, function(memo, item) { - var frequencyBonus = (item.score * item.count) / frequencyImportance; - return memo + item.score + frequencyBonus; - }, 0); - - matchingIdScoreObjects[id].foundWords[word]+=wordFrequency; - - if (isFullMatch) { - matchingIdScoreObjects[id].score += wordFrequencyHitScore; - } else { - matchingIdScoreObjects[id].score += wordFrequencyHitScore * partMatchRatio; - } - } - } - - function filterAndSortQualifyingMatches(matchingIdScoreObjects) { - var allowedScoreObjects = _.filter(matchingIdScoreObjects, function(item) { - return isModelSearchable(item.model); - }); - var qualifyingScoreThreshold = 1/scoreQualificationThreshold; - var qualifyingMatches = _.filter(allowedScoreObjects, function(item) { - //remove items which don't meet the score threshold - return item.score >= qualifyingScoreThreshold; - }); - qualifyingMatches = _.sortBy(qualifyingMatches, function(item) { - //sort by highest score first - return 1 / item.score; - }); - return qualifyingMatches; - } - - function isModelSearchable(model) { - var trail = model.getParents(true); - var config = model.get("_search"); - if (config && config._isEnabled === false) return false; - if (model.get("_isLocked")) return false; - - var firstDisabledTrailItem = trail.find(function(item) { - var config = item.get("_search"); - if (item.get("_isLocked")) return true; - if (config && config._isEnabled === false) return true; - return false; - }); - - - return (firstDisabledTrailItem === undefined); - } - - function mapSearchPhrasesToMatchingWords(matchingIdScoreObjects) { - for (var i = 0, l = matchingIdScoreObjects.length; i < l; i++) { - var matchingPhrases = []; - var scoreObject = matchingIdScoreObjects[i]; - var foundWords = _.keys(scoreObject.foundWords); - var modelPhrases = scoreObject.model.get("_searchProfile")._phrases; - for (var p = 0, lp = modelPhrases.length; p < lp; p++) { - var modelPhrase = modelPhrases[p]; - if (!modelPhrase.searchAttribute._allowTextPreview) continue; - - if (_.intersection(foundWords, _.keys(modelPhrase.words)).length > 0) { - matchingPhrases.push(modelPhrase); - } - } - scoreObject.foundPhrases = matchingPhrases; - } - } - - var findWords = getFindPhraseWords(findPhrase); - var matchingIdScoreObjects = getMatchingIdScoreObjects(findWords); - mapSearchPhrasesToMatchingWords(matchingIdScoreObjects); - var qualifyingMatchedScoreObjects = filterAndSortQualifyingMatches(matchingIdScoreObjects); - - return qualifyingMatchedScoreObjects; - } - - }, Backbone.Events); - - - search.initialize(); - - window.search = search; - - return search; +define([ + "coreJS/adapt", + "coreModels/adaptModel" +], function (Adapt, AdaptModel) { + + if (!AdaptModel.prototype.getParents) { + + AdaptModel.prototype.getParents = function (shouldIncludeChild) { + var parents = []; + var context = this; + + if (shouldIncludeChild) parents.push(context); + + while (context.has("_parentId")) { + context = context.getParent(); + parents.push(context); + } + + return parents.length ? new Backbone.Collection(parents) : null; + } + + } + + var searchDefaults = { //override in course.json "_search": {} + + _searchAttributes: [ + { + "_attributeName": "_search", + "_level": 1, + "_allowTextPreview": false + }, + { + "_attributeName": "_keywords", + "_level": 1, + "_allowTextPreview": false + }, + { + "_attributeName": "keywords", + "_level": 1, + "_allowTextPreview": false + }, + { + "_attributeName": "displayTitle", + "_level": 2, + "_allowTextPreview": true + }, + { + "_attributeName": "title", + "_level": 2, + "_allowTextPreview": false + }, + { + "_attributeName": "body", + "_level": 3, + "_allowTextPreview": true + }, + { + "_attributeName": "alt", + "_level": 4, + "_allowTextPreview": false + }, + { + "_attributeName": "_alt", + "_level": 4, + "_allowTextPreview": false + }, + { + "_attributeName": "_items", + "_level": 5, + "_allowTextPreview": false + }, + { + "_attributeName": "items", + "_level": 5, + "_allowTextPreview": false + }, + { + "_attributeName": "text", + "_level": 5, + "_allowTextPreview": true + } + ], + + _hideComponents: [ + "blank" + ], + + _hideTypes: [ + + ], + + _ignoreWords: [ + "a", "an", "and", "are", "as", "at", "be", "by", "for", + "from", "has", "he", "in", "is", "it", "its", "of", "on", + "that", "the", "to", "was", "were", "will", "wish", "", + ], + + _matchOn: { + _contentWordBeginsPhraseWord: false, + _contentWordContainsPhraseWord: false, + _contentWordEqualsPhraseWord: true, + _phraseWordBeginsContentWord: true + }, + + _scoreQualificationThreshold: 20, + _minimumWordLength: 2, + _frequencyImportance: 5 + + }; + + var wordCharacters = [ + '\u0041-\u005A', + '\u0030-\u0039', + '\u0061-\u007A', + '\u00AA', + '\u00B5', + '\u00BA', + '\u00C0-\u00D6', + '\u00D8-\u00F6', + '\u00F8-\u02C1', + '\u02C6-\u02D1', + '\u02E0-\u02E4', + '\u02EC', + '\u02EE', + '\u0370-\u0374', + '\u0376', + '\u0377', + '\u037A-\u037D', + '\u0386', + '\u0388-\u038A', + '\u038C', + '\u038E-\u03A1', + '\u03A3-\u03F5', + '\u03F7-\u0481', + '\u048A-\u0527', + '\u0531-\u0556', + '\u0559', + '\u0561-\u0587', + '\u05D0-\u05EA', + '\u05F0-\u05F2', + '\u0620-\u064A', + '\u066E', + '\u066F', + '\u0671-\u06D3', + '\u06D5', + '\u06E5', + '\u06E6', + '\u06EE', + '\u06EF', + '\u06FA-\u06FC', + '\u06FF', + '\u0710', + '\u0712-\u072F', + '\u074D-\u07A5', + '\u07B1', + '\u07CA-\u07EA', + '\u07F4', + '\u07F5', + '\u07FA', + '\u0800-\u0815', + '\u081A', + '\u0824', + '\u0828', + '\u0840-\u0858', + '\u08A0', + '\u08A2-\u08AC', + '\u0904-\u0939', + '\u093D', + '\u0950', + '\u0958-\u0961', + '\u0971-\u0977', + '\u0979-\u097F', + '\u0985-\u098C', + '\u098F', + '\u0990', + '\u0993-\u09A8', + '\u09AA-\u09B0', + '\u09B2', + '\u09B6-\u09B9', + '\u09BD', + '\u09CE', + '\u09DC', + '\u09DD', + '\u09DF-\u09E1', + '\u09F0', + '\u09F1', + '\u0A05-\u0A0A', + '\u0A0F', + '\u0A10', + '\u0A13-\u0A28', + '\u0A2A-\u0A30', + '\u0A32', + '\u0A33', + '\u0A35', + '\u0A36', + '\u0A38', + '\u0A39', + '\u0A59-\u0A5C', + '\u0A5E', + '\u0A72-\u0A74', + '\u0A85-\u0A8D', + '\u0A8F-\u0A91', + '\u0A93-\u0AA8', + '\u0AAA-\u0AB0', + '\u0AB2', + '\u0AB3', + '\u0AB5-\u0AB9', + '\u0ABD', + '\u0AD0', + '\u0AE0', + '\u0AE1', + '\u0B05-\u0B0C', + '\u0B0F', + '\u0B10', + '\u0B13-\u0B28', + '\u0B2A-\u0B30', + '\u0B32', + '\u0B33', + '\u0B35-\u0B39', + '\u0B3D', + '\u0B5C', + '\u0B5D', + '\u0B5F-\u0B61', + '\u0B71', + '\u0B83', + '\u0B85-\u0B8A', + '\u0B8E-\u0B90', + '\u0B92-\u0B95', + '\u0B99', + '\u0B9A', + '\u0B9C', + '\u0B9E', + '\u0B9F', + '\u0BA3', + '\u0BA4', + '\u0BA8-\u0BAA', + '\u0BAE-\u0BB9', + '\u0BD0', + '\u0C05-\u0C0C', + '\u0C0E-\u0C10', + '\u0C12-\u0C28', + '\u0C2A-\u0C33', + '\u0C35-\u0C39', + '\u0C3D', + '\u0C58', + '\u0C59', + '\u0C60', + '\u0C61', + '\u0C85-\u0C8C', + '\u0C8E-\u0C90', + '\u0C92-\u0CA8', + '\u0CAA-\u0CB3', + '\u0CB5-\u0CB9', + '\u0CBD', + '\u0CDE', + '\u0CE0', + '\u0CE1', + '\u0CF1', + '\u0CF2', + '\u0D05-\u0D0C', + '\u0D0E-\u0D10', + '\u0D12-\u0D3A', + '\u0D3D', + '\u0D4E', + '\u0D60', + '\u0D61', + '\u0D7A-\u0D7F', + '\u0D85-\u0D96', + '\u0D9A-\u0DB1', + '\u0DB3-\u0DBB', + '\u0DBD', + '\u0DC0-\u0DC6', + '\u0E01-\u0E30', + '\u0E32', + '\u0E33', + '\u0E40-\u0E46', + '\u0E81', + '\u0E82', + '\u0E84', + '\u0E87', + '\u0E88', + '\u0E8A', + '\u0E8D', + '\u0E94-\u0E97', + '\u0E99-\u0E9F', + '\u0EA1-\u0EA3', + '\u0EA5', + '\u0EA7', + '\u0EAA', + '\u0EAB', + '\u0EAD-\u0EB0', + '\u0EB2', + '\u0EB3', + '\u0EBD', + '\u0EC0-\u0EC4', + '\u0EC6', + '\u0EDC-\u0EDF', + '\u0F00', + '\u0F40-\u0F47', + '\u0F49-\u0F6C', + '\u0F88-\u0F8C', + '\u1000-\u102A', + '\u103F', + '\u1050-\u1055', + '\u105A-\u105D', + '\u1061', + '\u1065', + '\u1066', + '\u106E-\u1070', + '\u1075-\u1081', + '\u108E', + '\u10A0-\u10C5', + '\u10C7', + '\u10CD', + '\u10D0-\u10FA', + '\u10FC-\u1248', + '\u124A-\u124D', + '\u1250-\u1256', + '\u1258', + '\u125A-\u125D', + '\u1260-\u1288', + '\u128A-\u128D', + '\u1290-\u12B0', + '\u12B2-\u12B5', + '\u12B8-\u12BE', + '\u12C0', + '\u12C2-\u12C5', + '\u12C8-\u12D6', + '\u12D8-\u1310', + '\u1312-\u1315', + '\u1318-\u135A', + '\u1380-\u138F', + '\u13A0-\u13F4', + '\u1401-\u166C', + '\u166F-\u167F', + '\u1681-\u169A', + '\u16A0-\u16EA', + '\u1700-\u170C', + '\u170E-\u1711', + '\u1720-\u1731', + '\u1740-\u1751', + '\u1760-\u176C', + '\u176E-\u1770', + '\u1780-\u17B3', + '\u17D7', + '\u17DC', + '\u1820-\u1877', + '\u1880-\u18A8', + '\u18AA', + '\u18B0-\u18F5', + '\u1900-\u191C', + '\u1950-\u196D', + '\u1970-\u1974', + '\u1980-\u19AB', + '\u19C1-\u19C7', + '\u1A00-\u1A16', + '\u1A20-\u1A54', + '\u1AA7', + '\u1B05-\u1B33', + '\u1B45-\u1B4B', + '\u1B83-\u1BA0', + '\u1BAE', + '\u1BAF', + '\u1BBA-\u1BE5', + '\u1C00-\u1C23', + '\u1C4D-\u1C4F', + '\u1C5A-\u1C7D', + '\u1CE9-\u1CEC', + '\u1CEE-\u1CF1', + '\u1CF5', + '\u1CF6', + '\u1D00-\u1DBF', + '\u1E00-\u1F15', + '\u1F18-\u1F1D', + '\u1F20-\u1F45', + '\u1F48-\u1F4D', + '\u1F50-\u1F57', + '\u1F59', + '\u1F5B', + '\u1F5D', + '\u1F5F-\u1F7D', + '\u1F80-\u1FB4', + '\u1FB6-\u1FBC', + '\u1FBE', + '\u1FC2-\u1FC4', + '\u1FC6-\u1FCC', + '\u1FD0-\u1FD3', + '\u1FD6-\u1FDB', + '\u1FE0-\u1FEC', + '\u1FF2-\u1FF4', + '\u1FF6-\u1FFC', + '\u2071', + '\u207F', + '\u2090-\u209C', + '\u2102', + '\u2107', + '\u210A-\u2113', + '\u2115', + '\u2119-\u211D', + '\u2124', + '\u2126', + '\u2128', + '\u212A-\u212D', + '\u212F-\u2139', + '\u213C-\u213F', + '\u2145-\u2149', + '\u214E', + '\u2183', + '\u2184', + '\u2C00-\u2C2E', + '\u2C30-\u2C5E', + '\u2C60-\u2CE4', + '\u2CEB-\u2CEE', + '\u2CF2', + '\u2CF3', + '\u2D00-\u2D25', + '\u2D27', + '\u2D2D', + '\u2D30-\u2D67', + '\u2D6F', + '\u2D80-\u2D96', + '\u2DA0-\u2DA6', + '\u2DA8-\u2DAE', + '\u2DB0-\u2DB6', + '\u2DB8-\u2DBE', + '\u2DC0-\u2DC6', + '\u2DC8-\u2DCE', + '\u2DD0-\u2DD6', + '\u2DD8-\u2DDE', + '\u2E2F', + '\u3005', + '\u3006', + '\u3031-\u3035', + '\u303B', + '\u303C', + '\u3041-\u3096', + '\u309D-\u309F', + '\u30A1-\u30FA', + '\u30FC-\u30FF', + '\u3105-\u312D', + '\u3131-\u318E', + '\u31A0-\u31BA', + '\u31F0-\u31FF', + '\u3400-\u4DB5', + '\u4E00-\u9FCC', + '\uA000-\uA48C', + '\uA4D0-\uA4FD', + '\uA500-\uA60C', + '\uA610-\uA61F', + '\uA62A', + '\uA62B', + '\uA640-\uA66E', + '\uA67F-\uA697', + '\uA6A0-\uA6E5', + '\uA717-\uA71F', + '\uA722-\uA788', + '\uA78B-\uA78E', + '\uA790-\uA793', + '\uA7A0-\uA7AA', + '\uA7F8-\uA801', + '\uA803-\uA805', + '\uA807-\uA80A', + '\uA80C-\uA822', + '\uA840-\uA873', + '\uA882-\uA8B3', + '\uA8F2-\uA8F7', + '\uA8FB', + '\uA90A-\uA925', + '\uA930-\uA946', + '\uA960-\uA97C', + '\uA984-\uA9B2', + '\uA9CF', + '\uAA00-\uAA28', + '\uAA40-\uAA42', + '\uAA44-\uAA4B', + '\uAA60-\uAA76', + '\uAA7A', + '\uAA80-\uAAAF', + '\uAAB1', + '\uAAB5', + '\uAAB6', + '\uAAB9-\uAABD', + '\uAAC0', + '\uAAC2', + '\uAADB-\uAADD', + '\uAAE0-\uAAEA', + '\uAAF2-\uAAF4', + '\uAB01-\uAB06', + '\uAB09-\uAB0E', + '\uAB11-\uAB16', + '\uAB20-\uAB26', + '\uAB28-\uAB2E', + '\uABC0-\uABE2', + '\uAC00-\uD7A3', + '\uD7B0-\uD7C6', + '\uD7CB-\uD7FB', + '\uF900-\uFA6D', + '\uFA70-\uFAD9', + '\uFB00-\uFB06', + '\uFB13-\uFB17', + '\uFB1D', + '\uFB1F-\uFB28', + '\uFB2A-\uFB36', + '\uFB38-\uFB3C', + '\uFB3E', + '\uFB40', + '\uFB41', + '\uFB43', + '\uFB44', + '\uFB46-\uFBB1', + '\uFBD3-\uFD3D', + '\uFD50-\uFD8F', + '\uFD92-\uFDC7', + '\uFDF0-\uFDFB', + '\uFE70-\uFE74', + '\uFE76-\uFEFC', + '\uFF21-\uFF3A', + '\uFF41-\uFF5A', + '\uFF66-\uFFBE', + '\uFFC2-\uFFC7', + '\uFFCA-\uFFCF', + '\uFFD2-\uFFD7', + '\uFFDA-\uFFDC' + ]; + + wordCharacters = wordCharacters.join(''); + + var search = _.extend({ + + model: null, + + _searchableModels: null, + _wordIndex: null, + + _regularExpressions: { + matchNotWordBoundaries: new RegExp("[" + wordCharacters + "]+", "g"), + trimReplaceNonWordCharacters: new RegExp("^([^" + wordCharacters + "])+|([^" + wordCharacters + "])+$", "g"), + trimReplaceWhitespace: /^\s+|\s+$/g, + escapeRegExp: function (str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + } + }, + + initialize: function () { + this.setupListeners(); + }, + + setupListeners: function () { + this.listenTo(Adapt, { + "app:dataReady": this.onDataReady, + "app:languageChanged": this.clearSearchResults + }); + }, + + clearSearchResults: function () { + Adapt.trigger("search:filterTerms", ""); + }, + + onDataReady: function () { + var config = Adapt.course.get("_search"); + if (!config || config._isEnabled === false) return; + + this.setupConfig(); + this._searchableModels = this.collectModelTexts(); + this.makeModelTextProfiles(); + this.indexTextProfiles(); + Adapt.trigger("search-algorithm:ready"); + }, + + setupConfig: function () { + var model = Adapt.course.get("_search") || {}; + //make sure defaults are injected, but original model reference is maintained + var modelWithDefaults = _.extend(searchDefaults, model); + + Adapt.course.set("_search", modelWithDefaults); + + this.model = new Backbone.Model(modelWithDefaults); + + }, + + collectModelTexts: function () { + + var searchAttributes = this.model.get("_searchAttributes"); + var hideComponents = this.model.get("_hideComponents"); + var hideTypes = this.model.get("_hideTypes"); + var regularExpressions = this._regularExpressions; + + + function combineAdaptModels() { + var rtn = [] + .concat(Adapt.contentObjects.models) + .concat(Adapt.articles.models) + .concat(Adapt.blocks.models) + .concat(Adapt.components.models); + + var filtered = _.filter(rtn, function (model) { + var type = model.get("_type"), + displayTitle, + title; + if (_.contains(hideTypes, type)) return false; + + if (type == "component") { + var component = model.get("_component"); + if (_.contains(hideComponents, component)) return false; + } + + if (model.has("displayTitle")) displayTitle = model.get("displayTitle").replace(regularExpressions.trimReplaceWhitespace, ""); + if (model.has("title")) title = model.get("title").replace(regularExpressions.trimReplaceWhitespace, ""); + + if (!displayTitle && !title) return false; + + return true; + }); + + return filtered; + } + + function getSearchableModels() { + var adaptModels = combineAdaptModels(); + + var searchable = []; + for (var i = 0, l = adaptModels.length; i < l; i++) { + var model = adaptModels[i]; + if (!isModelSearchable(model)) continue; + var json = model.toJSON(); + var searchProfile = { + "_raw": recursivelyCollectModelTexts(json) + }; + model.set("_searchProfile", searchProfile) + searchable.push(model); + } + + return new Backbone.Collection(searchable); + } + + function isModelSearchable(model) { + var trail = model.getParents(true); + var config = model.get("_search"); + if (config && config._isEnabled === false) return false; + + var firstDisabledTrailItem = trail.find(function (item) { + var config = item.get("_search"); + if (!config) return false; + if (config && config._isEnabled !== false) return false; + return true; + }); + return firstDisabledTrailItem === undefined; + } + + function recursivelyCollectModelTexts(json, level) { + var texts = []; + for (var i = 0, l = searchAttributes.length; i < l; i++) { + var attributeObject = searchAttributes[i]; + if (!json[attributeObject._attributeName]) continue; + switch (typeof json[attributeObject._attributeName]) { + case "object": + if (json[attributeObject._attributeName] instanceof Array) { + for (var sa = 0, sal = json[attributeObject._attributeName].length; sa < sal; sa++) { + switch (typeof json[attributeObject._attributeName][sa]) { + case "object": + texts = texts.concat(recursivelyCollectModelTexts(json[attributeObject._attributeName][sa], attributeObject._level)); + break; + case "string": + addString(json[attributeObject._attributeName][sa], attributeObject._level, attributeObject); + } + } + } else { + texts = texts.concat(recursivelyCollectModelTexts(json[attributeObject._attributeName], attributeObject._level)); + } + break; + case "string": + addString(json[attributeObject._attributeName], level, attributeObject); + break; + } + } + return texts; + + function addString(string, level, attributeObject) { + var textLevel = level || attributeObject._level; + var text = $("
" + string.replace(regularExpressions.trimReplaceWhitespace, "") + "
").text(); + text = text.replace(regularExpressions.trimReplaceWhitespace, ""); + if (!text) return; + texts.push({ + score: 1 / textLevel, + text: text, + searchAttribute: attributeObject + }); + } + } + + return getSearchableModels(); + + }, + + makeModelTextProfiles: function () { + // Handle _ignoreWords as a special case to support the authoring tool + var ignoreWords = this.model.get('_ignoreWords') instanceof Array + ? this.model.get("_ignoreWords") + : this.model.get("_ignoreWords").split(','); + + var regularExpressions = this._regularExpressions; + var searchAttributes = this.model.get("_searchAttributes"); + var minimumWordLength = this.model.get("_minimumWordLength"); + + var scores = _.uniq(_.pluck(searchAttributes, "_level")); + scores = _.map(scores, function (l) { return 1 / l; }); + + for (var i = 0, l = this._searchableModels.models.length; i < l; i++) { + var item = this._searchableModels.models[i]; + var profile = item.get("_searchProfile"); + makeModelPhraseProfile(profile); + makeModelPhraseWordAndWordProfile(profile); + } + + function makeModelPhraseProfile(profile) { + profile._phrases = []; + var phrases = _.groupBy(profile._raw, function (phrase) { + return phrase.text; + }); + for (var p in phrases) { + var bestItem = _.max(phrases[p], function (item) { + return item.score; + }); + profile._phrases.push({ + searchAttribute: bestItem.searchAttribute, + phrase: bestItem.text, + score: bestItem.score, + words: [] + }); + } + return profile; + } + + function makeModelPhraseWordAndWordProfile(profile) { + profile._words = []; + for (var l = 0, ll = scores.length; l < ll; l++) { + var score = scores[l]; + var phrases = _.where(profile._phrases, { score: score }); + var scoreWords = []; + for (var i = 0, pl = phrases.length; i < pl; i++) { + var phraseObject = phrases[i]; + var chunks = phraseObject.phrase.match(regularExpressions.matchNotWordBoundaries); + var words = _.map(chunks, function (chunk) { + return chunk.replace(regularExpressions.trimReplaceNonWordCharacters, ""); + }); + phraseObject.words = _.countBy(words, function (word) { return word.toLowerCase(); }); + phraseObject.words = _.omit(phraseObject.words, ignoreWords); + scoreWords = scoreWords.concat(words); + } + scoreWords = _.filter(scoreWords, function (word) { return word.length >= minimumWordLength; }); + scoreWords = _.countBy(scoreWords, function (word) { return word.toLowerCase(); }); + scoreWords = _.omit(scoreWords, ignoreWords); + for (var word in scoreWords) { + profile._words.push({ + word: word, + score: score, + count: scoreWords[word] + }); + } + } + return profile; + } + + }, + + indexTextProfiles: function () { + + this._wordIndex = {}; + + for (var i = 0, il = this._searchableModels.models.length; i < il; i++) { + var item = this._searchableModels.models[i]; + var id = item.get("_id"); + var searchProfile = item.get("_searchProfile"); + + for (var w = 0, wl = searchProfile._words.length; w < wl; w++) { + var word = searchProfile._words[w].word; + if (Object.prototype.hasOwnProperty && !this._wordIndex.hasOwnProperty(word)) { + this._wordIndex[word] = []; + } else { + this._wordIndex[word] = this._wordIndex[word] || []; + } + this._wordIndex[word].push(id); + } + + } + + for (var word in this._wordIndex) { + this._wordIndex[word] = _.uniq(this._wordIndex[word]); + } + + }, + + find: function (findPhrase) { + + /* + returns [ + { + score: (float)score = bestPhraseAttributeScore * (wordOccurencesInSection * frequencyMultiplier) + model: model, + foundWords: { + "foundWord": (int)occurencesInSection + }, + foundPhrases: [ + { + "score": (float)score = (1/attributeLevel) + "phrase": "Test phrase", + "words": { + "Test": (int)occurencesInPhrase, + "phrase": (int)occurencesInPhrase + } + } + ] + } + ]; + */ + + var regularExpressions = this._regularExpressions; + var wordIndex = this._wordIndex; + // Handle _ignoreWords as a special case to support the authoring tool + var ignoreWords = this.model.get('_ignoreWords') instanceof Array + ? this.model.get("_ignoreWords") + : this.model.get("_ignoreWords").split(','); + var scoreQualificationThreshold = this.model.get("_scoreQualificationThreshold"); + var minimumWordLength = this.model.get("_minimumWordLength"); + var frequencyImportance = this.model.get("_frequencyImportance"); + var matchOn = this.model.get("_matchOn") || {}; + + var json = this._searchableModels.toJSON(); + + function getFindPhraseWords(findPhrase) { + var findChunks = findPhrase.match(regularExpressions.matchNotWordBoundaries); + var findWords = _.map(findChunks, function (chunk) { + return chunk.replace(regularExpressions.trimReplaceNonWordCharacters, ""); + }); + findWords = _.countBy(findWords, function (word) { return word.toLowerCase(); }); + findWords = _.omit(findWords, ignoreWords); + findWords = _.omit(findWords, function (count, item) { + return item.length < minimumWordLength; + }); + return findWords; + } + + function getMatchingIdScoreObjects(findWords) { + var matchingIdScoreObjects = {}; + + for (var findWord in findWords) { + for (var indexWord in wordIndex) { + //allow only start matches on findWord beginning with indexWord i.e. find: oneness begins with index: one + var rIndexWordBegins = new RegExp("^" + regularExpressions.escapeRegExp(indexWord), "g"); + //allow all matches on indexWord containing findWord i.e. index: someone contains find: one, index: anti-money contains find: money + var rFindWordContains = new RegExp(regularExpressions.escapeRegExp(findWord), "g"); + //allow only start matches on indexWord beginning with findWord i.e. find: one begins index: oneness + var rFindWordBegins = new RegExp("^" + regularExpressions.escapeRegExp(findWord), "g"); + + var isIndexBeginsMatch = matchOn._contentWordBeginsPhraseWord === false ? false : rIndexWordBegins.test(findWord); + var isFindContainsMatch = matchOn._contentWordContainsPhraseWord === false ? false : rFindWordContains.test(indexWord); + var isFindBeginsMatch = matchOn._phraseWordBeginsContentWord === false ? false : rFindWordBegins.test(indexWord); + + var isFullMatch = matchOn._contentWordEqualsPhraseWord == false ? false : findWord == indexWord; + var isPartMatch = isIndexBeginsMatch || isFindContainsMatch || isFindBeginsMatch; + + if (!isFullMatch && !isPartMatch) continue; + + var partMatchRatio = 1; + if (isPartMatch && !isFullMatch) { + if (findWord.length > indexWord.length) partMatchRatio = indexWord.length / findWord.length; + else partMatchRatio = findWord.length / indexWord.length + } + + updateIdScoreObjectsForWord(matchingIdScoreObjects, indexWord, isFullMatch, partMatchRatio); + + } + } + return _.values(matchingIdScoreObjects); + } + + function updateIdScoreObjectsForWord(matchingIdScoreObjects, word, isFullMatch, partMatchRatio) { + + for (var i = 0, l = wordIndex[word].length; i < l; i++) { + var id = wordIndex[word][i]; + var model = _.findWhere(json, { _id: id }); + + if (matchingIdScoreObjects[id] === undefined) { + matchingIdScoreObjects[id] = { + score: 0, + foundWords: {}, + foundPhrases: null, + model: Adapt.findById(id) + }; + } + + if (matchingIdScoreObjects[id].foundWords[word] === undefined) matchingIdScoreObjects[id].foundWords[word] = 0; + + var allPhraseWordRatingObjects = _.where(model._searchProfile._words, { word: word }); + var wordFrequency = _.reduce(allPhraseWordRatingObjects, function (memo, item) { return memo + item.count; }, 0); + var wordFrequencyHitScore = _.reduce(allPhraseWordRatingObjects, function (memo, item) { + var frequencyBonus = (item.score * item.count) / frequencyImportance; + return memo + item.score + frequencyBonus; + }, 0); + + matchingIdScoreObjects[id].foundWords[word] += wordFrequency; + + if (isFullMatch) { + matchingIdScoreObjects[id].score += wordFrequencyHitScore; + } else { + matchingIdScoreObjects[id].score += wordFrequencyHitScore * partMatchRatio; + } + } + } + + function filterAndSortQualifyingMatches(matchingIdScoreObjects) { + var allowedScoreObjects = _.filter(matchingIdScoreObjects, function (item) { + return isModelSearchable(item.model); + }); + var qualifyingScoreThreshold = 1 / scoreQualificationThreshold; + var qualifyingMatches = _.filter(allowedScoreObjects, function (item) { + //remove items which don't meet the score threshold + return item.score >= qualifyingScoreThreshold; + }); + qualifyingMatches = _.sortBy(qualifyingMatches, function (item) { + //sort by highest score first + return 1 / item.score; + }); + return qualifyingMatches; + } + + function isModelSearchable(model) { + var trail = model.getParents(true); + var config = model.get("_search"); + if (config && config._isEnabled === false) return false; + if (model.get("_isLocked")) return false; + + var firstDisabledTrailItem = trail.find(function (item) { + var config = item.get("_search"); + if (item.get("_isLocked")) return true; + if (config && config._isEnabled === false) return true; + return false; + }); + + + return (firstDisabledTrailItem === undefined); + } + + function mapSearchPhrasesToMatchingWords(matchingIdScoreObjects) { + for (var i = 0, l = matchingIdScoreObjects.length; i < l; i++) { + var matchingPhrases = []; + var scoreObject = matchingIdScoreObjects[i]; + var foundWords = _.keys(scoreObject.foundWords); + var modelPhrases = scoreObject.model.get("_searchProfile")._phrases; + for (var p = 0, lp = modelPhrases.length; p < lp; p++) { + var modelPhrase = modelPhrases[p]; + if (!modelPhrase.searchAttribute._allowTextPreview) continue; + + if (_.intersection(foundWords, _.keys(modelPhrase.words)).length > 0) { + matchingPhrases.push(modelPhrase); + } + } + scoreObject.foundPhrases = matchingPhrases; + } + } + + var findWords = getFindPhraseWords(findPhrase); + var matchingIdScoreObjects = getMatchingIdScoreObjects(findWords); + mapSearchPhrasesToMatchingWords(matchingIdScoreObjects); + var qualifyingMatchedScoreObjects = filterAndSortQualifyingMatches(matchingIdScoreObjects); + + return qualifyingMatchedScoreObjects; + } + + }, Backbone.Events); + + + search.initialize(); + + window.search = search; + + return search; }); diff --git a/js/searchDrawerItemView.js b/js/searchDrawerItemView.js index 99fee58..77f0ae4 100644 --- a/js/searchDrawerItemView.js +++ b/js/searchDrawerItemView.js @@ -37,7 +37,6 @@ define([ if(event) event.preventDefault(); var searchVal = this.$(".search-box").val(); - console.log("search: " + searchVal); Adapt.trigger("search:filterTerms", searchVal); } diff --git a/js/searchResultsView.js b/js/searchResultsView.js index 672d6df..7005b55 100644 --- a/js/searchResultsView.js +++ b/js/searchResultsView.js @@ -5,8 +5,8 @@ define(function(require){ var replaceTagsRegEx = /\<{1}[^\>]+\>/g; var replaceEndTagsRegEx = /\<{1}\/{1}[^\>]+\>/g; - - var SearchResultsView = Backbone.View.extend({ + + var SearchResultsView = Backbone.View.extend({ className : 'search-results inactive', @@ -194,8 +194,8 @@ define(function(require){ Adapt.trigger('drawer:closeDrawer'); } - }); + }); - return SearchResultsView; + return SearchResultsView; }); \ No newline at end of file diff --git a/less/search.less b/less/search.less index 6d20c02..6a94d52 100755 --- a/less/search.less +++ b/less/search.less @@ -1,15 +1,15 @@ // Search Box .drawer-item-open.search { - border-bottom: 1px solid darken(@item-color, 10%); + border-bottom: 1px solid darken(@item-color, 10%); } .list-item-open.search { - position: relative; - padding:@drawer-item-padding; - display: block; - width: 100%; - color: @drawer-item-text-color; + position: relative; + padding:@drawer-item-padding; + display: block; + width: 100%; + color: @drawer-item-text-color; } .drawer-item-description-inner { @@ -18,102 +18,102 @@ } .list-item.search { - padding:5px 0; - padding-left:0; - position: relative; + padding:5px 0; + padding-left:0; + position: relative; } .search-box { height: 24px; - border: none; - width: 100%; - display: block; + border: none; + width: 100%; + display: block; } .start-search { - text-decoration: none; - top: 50%; - right: 0; - margin-top: -12px; - position: absolute; - color: @drawer-icon-color; - &:hover { + text-decoration: none; + top: 50%; + right: 0; + margin-top: -12px; + position: absolute; + color: @drawer-icon-color; + &:hover { color:@drawer-icon-hover-color; } } .search-drawer { - width: initial !important; + width: auto !important; - &:hover { - background-color:initial !important; - } + &:hover { + background-color:initial !important; + } } // Search Result .search-results { - &.inactive { - display:none; - } - - .no-results { - padding:0px; - color: @drawer-item-text-color; - border-bottom: 1px solid darken(spin(@quaternary-color, 180), 10%); - width: 100%; - font-size: 16px; - - .search-result-inner { - padding: 10px 5px;; - } - - div { - padding: 0px; - } - } - - .search-result { - padding:0px; - color: @drawer-item-text-color; - border-bottom: 1px solid darken(spin(@quaternary-color, 180), 10%); - width: 100%; - .no-touch &:hover { - color:@drawer-item-text-hover-color; - background-color: @drawer-item-hover-color; - border-bottom: 1px solid darken(spin(@quaternary-color, 180), 10%); - } - - .search-result-inner { - padding: 10px 5px; - } - - .found { - background-color: yellow; - color: black; - - } - - } + &.inactive { + display:none; + } + + .no-results { + padding:0px; + color: @drawer-item-text-color; + border-bottom: 1px solid darken(spin(@quaternary-color, 180), 10%); + width: 100%; + font-size: 16px; + + .search-result-inner { + padding: 10px 5px;; + } + + div { + padding: 0px; + } + } + + .search-result { + padding:0px; + color: @drawer-item-text-color; + border-bottom: 1px solid darken(spin(@quaternary-color, 180), 10%); + width: 100%; + .no-touch &:hover { + color:@drawer-item-text-hover-color; + background-color: @drawer-item-hover-color; + border-bottom: 1px solid darken(spin(@quaternary-color, 180), 10%); + } + + .search-result-inner { + padding: 10px 5px; + } + + .found { + background-color: yellow; + color: black; + + } + + } } .result-title { - font-size:16px; + font-size:16px; font-weight:@font-weight-bold; color: @drawer-item-text-color; text-decoration: none; } .result-foundwords { - font-size:14px; + font-size:14px; color: @drawer-item-text-color; text-decoration: none; padding: 0 0 0 10px; } .result-preview { - font-size:15px; + font-size:15px; color: @drawer-item-text-color; text-decoration: none; - padding: 3px 0 4px 10px; + padding: 3px 0 4px 10px; } diff --git a/properties.schema b/properties.schema index 37114b0..2f8d413 100644 --- a/properties.schema +++ b/properties.schema @@ -1,280 +1,310 @@ { - "type": "object", - "$schema": "http://json-schema.org/draft-04/schema", - "id": "http://jsonschema.net", - "required": false, - "globals": { - }, - "properties": { - "pluginLocations": { - "type": "object", - "required": true, - "properties": { - "contentobject": { - "type": "object", - "properties": { - "_search": { - "type": "object", - "required": false, - "legend": "Search", - "properties": { - "_isEnabled": { - "type": "boolean", - "required": true, - "default": true, - "title": "Enable search", - "inputType": { - "type": "Boolean", - "options": [true, false] - }, - "validators": [], - "help": "" - }, - "keywords": { - "type": "string", - "default": "", - "required": false, - "title": "Keywords", - "inputType": "Text", - "validators": [] - } - } - } - } - }, - "article": { - "type": "object", - "properties": { - "_search": { - "type": "object", - "required": false, - "legend": "Search", - "properties": { - "_isEnabled": { - "type": "boolean", - "required": true, - "default": true, - "title": "Enable search", - "inputType": { - "type": "Boolean", - "options": [true, false] - }, - "validators": [], - "help": "" - }, - "keywords": { - "type": "string", - "default": "", - "required": false, - "title": "Keywords", - "inputType": "Text", - "validators": [] - } - } - } - } - }, - "block": { - "type": "object", - "properties": { - "_search": { - "type": "object", - "required": false, - "legend": "Search", - "properties": { - "_isEnabled": { - "type": "boolean", - "required": true, - "default": true, - "title": "Enable search", - "inputType": { - "type": "Boolean", - "options": [true, false] - }, - "validators": [], - "help": "" - }, - "keywords": { - "type": "string", - "default": "", - "required": false, - "title": "Keywords", - "inputType": "Text", - "validators": [] - } - } - } - } - }, - "component": { - "type": "object", - "properties": { - "_search": { - "type": "object", - "required": false, - "legend": "Search", - "properties": { - "_isEnabled": { - "type": "boolean", - "required": true, - "default": true, - "title": "Enable search", - "inputType": { - "type": "Boolean", - "options": [true, false] - }, - "validators": [], - "help": "" - }, - "keywords": { - "type": "string", - "default": "", - "required": false, - "title": "Keywords", - "inputType": "Text", - "validators": [] - } - } - } - } - }, - "config": { - "type": "object" - }, - "course": { - "type": "object", - "properties": { - "_search": { - "type": "object", - "required": false, - "legend": "Search", - "properties": { - "_isEnabled": { - "type": "boolean", - "required": true, - "default": true, - "title": "Enable search", - "inputType": { - "type": "Boolean", - "options": [true, false] - }, - "validators": [], - "help": "" - }, - "_previewWords": { - "type": "number", - "required": false, - "default": 10, - "title": "Preview words", - "inputType": "Number", - "validators": ["number"], - "help": "" - }, - "_previewCharacters": { - "type": "number", - "required": false, - "default": 30, - "title": "Preview characters", - "inputType": "Number", - "validators": ["number"], - "help": "" - }, - "_showFoundWords": { - "type": "boolean", - "required": true, - "default": true, - "title": "Show found words", - "inputType": { - "type": "Boolean", - "options": [true, false] - }, - "validators": [], - "help": "" - }, - "_showHighlights": { - "type": "boolean", - "required": true, - "default": true, - "title": "Show highlights", - "inputType": { - "type": "Boolean", - "options": [true, false] - }, - "validators": [], - "help": "" - }, - "_ignoreWords": { - "type": "string", - "default": "a,an,and,are,as,at,be,by,for,from,has,he,in,is,it,its,of,on,that,the,to,was,were,will,wish", - "required": true, - "title": "Ignored words", - "inputType": "Text", - "validators": [], - "help": "A comma-separated list of words which will be ignored by the search algorithm" - }, - "_minimumWordLength": { - "type": "number", - "required": false, - "default": 2, - "title": "Minimum word length", - "inputType": "Number", - "validators": ["number"], - "help": "Minimum number of characters which must be entered before triggering a search" - }, - "_frequencyImportance": { - "type": "number", - "required": false, - "default": 5, - "title": "Frequency importance", - "inputType": "Number", - "validators": ["number"], - "help": "Extra points for a repeat match" - }, - "title": { - "type": "string", - "default": "Search", - "required": false, - "title": "Title", - "inputType": "Text", - "validators": [] - }, - "description": { - "type": "string", - "default": "Enter search criteria", - "required": false, - "title": "Placeholder", - "inputType": "Text", - "validators": [] - }, - "placeholder": { - "type": "string", - "default": "", - "required": false, - "title": "Placeholder text for the search box", - "inputType": "Text", - "validators": [] - }, - "noResultsMessage": { - "type": "string", - "default": "No results found", - "required": false, - "title": "No results message", - "inputType": "Text", - "validators": [] - }, - "awaitingResultsMessage": { - "type": "string", - "default": "Formulating results...", - "required": false, - "title": "Processing results message", - "inputType": "Text", - "validators": [] - } - } - } - } - } - } - } - } + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema", + "id": "http://jsonschema.net", + "required": false, + "globals": { + }, + "properties": { + "pluginLocations": { + "type": "object", + "required": true, + "properties": { + "contentobject": { + "type": "object", + "properties": { + "_search": { + "type": "object", + "required": false, + "legend": "Search", + "properties": { + "_isEnabled": { + "type": "boolean", + "required": true, + "default": true, + "title": "Enable search", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "keywords": { + "type": "string", + "default": "", + "required": false, + "title": "Keywords", + "inputType": "Text", + "validators": [] + } + } + } + } + }, + "article": { + "type": "object", + "properties": { + "_search": { + "type": "object", + "required": false, + "legend": "Search", + "properties": { + "_isEnabled": { + "type": "boolean", + "required": true, + "default": true, + "title": "Enable search", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "keywords": { + "type": "string", + "default": "", + "required": false, + "title": "Keywords", + "inputType": "Text", + "validators": [] + } + } + } + } + }, + "block": { + "type": "object", + "properties": { + "_search": { + "type": "object", + "required": false, + "legend": "Search", + "properties": { + "_isEnabled": { + "type": "boolean", + "required": true, + "default": true, + "title": "Enable search", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "keywords": { + "type": "string", + "default": "", + "required": false, + "title": "Keywords", + "inputType": "Text", + "validators": [] + } + } + } + } + }, + "component": { + "type": "object", + "properties": { + "_search": { + "type": "object", + "required": false, + "legend": "Search", + "properties": { + "_isEnabled": { + "type": "boolean", + "required": true, + "default": true, + "title": "Enable search", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "keywords": { + "type": "string", + "default": "", + "required": false, + "title": "Keywords", + "inputType": "Text", + "validators": [] + } + } + } + } + }, + "config": { + "type": "object" + }, + "course": { + "type": "object", + "properties": { + "_search": { + "type": "object", + "required": false, + "legend": "Search", + "properties": { + "_isEnabled": { + "type": "boolean", + "required": true, + "default": true, + "title": "Enable search", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "_drawerOrder": { + "type": "number", + "required": false, + "default": 0, + "title": "Drawer Order", + "inputType": "Number", + "validators": ["number"] + }, + "_matchOn": { + "type": "object", + "required": false, + "legend": "Match On Rules", + "properties": { + "_contentWordBeginsPhraseWord": { + "type": "boolean", + "required": true, + "default": false, + "title": "A word in the content begins the search phrase word", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "_contentWordContainsPhraseWord": { + "type": "boolean", + "required": true, + "default": false, + "title": "A word in the content contains the search phrase word", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "_contentWordEqualsPhraseWord": { + "type": "boolean", + "required": true, + "default": true, + "title": "A word in the content equals the search phrase word", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "_phraseWordBeginsContentWord": { + "type": "boolean", + "required": true, + "default": true, + "title": "A word in the content starts with the search phrase word", + "inputType": "Checkbox", + "validators": [], + "help": "" + } + } + }, + "_previewWords": { + "type": "number", + "required": false, + "default": 10, + "title": "Preview words", + "inputType": "Number", + "validators": ["number"], + "help": "" + }, + "_previewCharacters": { + "type": "number", + "required": false, + "default": 30, + "title": "Preview characters", + "inputType": "Number", + "validators": ["number"], + "help": "" + }, + "_showFoundWords": { + "type": "boolean", + "required": true, + "default": true, + "title": "Show found words", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "_showHighlights": { + "type": "boolean", + "required": true, + "default": true, + "title": "Show highlights", + "inputType": "Checkbox", + "validators": [], + "help": "" + }, + "_ignoreWords": { + "type": "string", + "default": "a,an,and,are,as,at,be,by,for,from,has,he,in,is,it,its,of,on,that,the,to,was,were,will,wish", + "required": true, + "title": "Ignored words", + "inputType": "Text", + "validators": [], + "help": "A comma-separated list of words which will be ignored by the search algorithm" + }, + "_minimumWordLength": { + "type": "number", + "required": false, + "default": 2, + "title": "Minimum word length", + "inputType": "Number", + "validators": ["number"], + "help": "Minimum number of characters which must be entered before triggering a search" + }, + "_frequencyImportance": { + "type": "number", + "required": false, + "default": 5, + "title": "Frequency importance", + "inputType": "Number", + "validators": ["number"], + "help": "Extra points for a repeat match" + }, + "title": { + "type": "string", + "default": "Search", + "required": false, + "title": "Title", + "inputType": "Text", + "validators": [] + }, + "description": { + "type": "string", + "default": "Enter search criteria", + "required": false, + "title": "Placeholder", + "inputType": "Text", + "validators": [] + }, + "placeholder": { + "type": "string", + "default": "", + "required": false, + "title": "Placeholder text for the search box", + "inputType": "Text", + "validators": [] + }, + "noResultsMessage": { + "type": "string", + "default": "No results found", + "required": false, + "title": "No results message", + "inputType": "Text", + "validators": [] + }, + "awaitingResultsMessage": { + "type": "string", + "default": "Formulating results...", + "required": false, + "title": "Processing results message", + "inputType": "Text", + "validators": [] + } + } + } + } + } + } + } + } } diff --git a/templates/searchSingleItem.hbs b/templates/searchSingleItem.hbs index 234f66c..bc1c639 100644 --- a/templates/searchSingleItem.hbs +++ b/templates/searchSingleItem.hbs @@ -1,10 +1,10 @@
-
-
-
{{{title}}}
-
-
-
{{{a11y_text description}}}
-
-
+
+
+
{{{title}}}
+
+
+
{{{a11y_text description}}}
+
+
\ No newline at end of file