diff --git a/README.md b/README.md index 8ef22c5..f56b69f 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ A list of search keywords/phrases to be associated with the contentObject/articl No known limitations. ---------------------------- -**Version number:** 3.1.0 adapt learning logo -**Framework versions:** 5.5+ +**Version number:** 4.0.0 adapt learning logo +**Framework versions:** 5.8+ **Author / maintainer:** Kineo and community with [contributors](https://github.com/cgkineo/adapt-search/graphs/contributors) **Accessibility support:** WAI AA **RTL support:** No diff --git a/bower.json b/bower.json index 76f91f0..49fb2e9 100644 --- a/bower.json +++ b/bower.json @@ -4,8 +4,8 @@ "type": "git", "url": "git://github.com/cgkineo/adapt-search" }, - "version": "3.1.1", - "framework": ">=5.5", + "version": "4.0.0", + "framework": ">=5.8", "homepage": "https://github.com/cgkineo/adapt-search", "issues": "https://github.com/cgkineo/adapt-search/issues/", "extension": "search", diff --git a/js/SEARCH_DEFAULTS.js b/js/SEARCH_DEFAULTS.js new file mode 100644 index 0000000..1379211 --- /dev/null +++ b/js/SEARCH_DEFAULTS.js @@ -0,0 +1,105 @@ +// override in course.json "_search": {} +const SEARCH_DEFAULTS = { + + title: 'Search', + description: 'Type in search words', + placeholder: '', + noResultsMessage: 'Sorry, no results were found', + awaitingResultsMessage: 'Formulating results...', + _previewWords: 15, + _previewCharacters: 30, + _showHighlights: true, + _showFoundWords: true, + + _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: '_options', + _level: 5, + _allowTextPreview: false + }, + { + _attributeName: 'items', + _level: 5, + _allowTextPreview: false + }, + { + _attributeName: 'text', + _level: 5, + _allowTextPreview: true + } + ], + + _hideComponents: [ + 'blank', + 'assessmentResults' + ], + + _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 + +}; + +export default SEARCH_DEFAULTS; diff --git a/js/SearchObject.js b/js/SearchObject.js new file mode 100644 index 0000000..08fce39 --- /dev/null +++ b/js/SearchObject.js @@ -0,0 +1,19 @@ +import Adapt from 'core/js/adapt'; + +/** + * Represents all of the settings used for the search, the search results and + * the user interface search state. + */ +export default class SearchObject { + + constructor(shouldSearch, searchPhrase, searchResults) { + const searchConfig = Adapt.course.get('_search'); + Object.assign(this, searchConfig, { + query: searchPhrase, + searchResults: searchResults, + isAwaitingResults: (searchPhrase.length !== 0 && !shouldSearch), + isBlank: (searchPhrase.length === 0) + }); + } + +} diff --git a/js/SearchResult.js b/js/SearchResult.js new file mode 100644 index 0000000..00e7fce --- /dev/null +++ b/js/SearchResult.js @@ -0,0 +1,50 @@ +import Adapt from 'core/js/adapt'; + +/** + * Represents a matching model, its matching words and phrases and the score of + * the search result. + */ +export default class SearchResult { + + constructor({ + searchableModel, + score = 0, + foundWords = [], + foundPhrases = [] + } = {}) { + this.searchableModel = searchableModel; + this.model = this.searchableModel.model; + this.score = score; + this.foundWords = foundWords; + this.foundPhrases = foundPhrases; + } + + /** + * Update search result score, words and phrases with a newly found word. + * @param {string} word + * @param {boolean} isFullMatch + * @param {number} partMatchRatio + */ + addFoundWord(word, isFullMatch, partMatchRatio) { + if (this.foundWords.find(({ word: matchWord }) => matchWord === word)) return; + const config = Adapt.course.get('_search'); + const frequencyImportance = config._frequencyImportance; + const searchableWord = this.searchableModel.words.find(({ word: matchWord }) => matchWord === word); + this.foundWords.push(searchableWord); + const frequencyBonus = (searchableWord.score * searchableWord.count) / frequencyImportance; + const wordFrequencyHitBonus = searchableWord.score + frequencyBonus; + this.score += isFullMatch + ? wordFrequencyHitBonus + : wordFrequencyHitBonus * partMatchRatio; + // Find all matching phrases + const foundWords = this.foundWords.map(({ word }) => word); + const phrases = this.searchableModel.phrases; + this.foundPhrases = []; + for (const phrase of phrases) { + if (!phrase.allowTextPreview) continue; + if (!_.intersection(foundWords, Object.keys(phrase.words)).length > 0) continue; + this.foundPhrases.push(phrase); + } + } + +} diff --git a/js/SearchableModel.js b/js/SearchableModel.js new file mode 100644 index 0000000..df9cf1a --- /dev/null +++ b/js/SearchableModel.js @@ -0,0 +1,83 @@ +import Adapt from 'core/js/adapt'; +import SearchablePhrase from './SearchablePhrase'; +import SearchableWord from './SearchableWord'; + +/** + * Provides a wrapper for models to extract the searchable phrases and words. + */ +export default class SearchableModel { + + constructor({ model } = {}) { + this.model = model; + this._phrases = null; + this._words = null; + } + + get isSearchable() { + const modelConfig = this.model.get('_search'); + if (modelConfig?._isEnabled === false) return false; + const isUnavailableInPage = this.model.getAncestorModels(true).some(model => ( + model.get('_search')?._isEnabled === false || + !model.get('_isAvailable') || + model.get('_isLocked') || + model.get('_isPartOfAssessment') + )); + if (isUnavailableInPage) return false; + const config = Adapt.course.get('_search'); + const hideComponents = config._hideComponents; + const hideTypes = config._hideTypes; + const component = this.model.get('_component'); + const type = this.model.get('_type'); + const shouldIgnore = hideTypes.includes(type) || (type === 'component' && hideComponents.includes(component)); + if (shouldIgnore) return false; + const displayTitle = this.model.get('displayTitle').trim(); + const title = this.model.get('title').trim(); + const hasTitleOrDisplayTitle = Boolean(displayTitle || title); + return hasTitleOrDisplayTitle; + } + + /** + * Return all searchable phrases in the model + * @returns {[SearchablePhrase]} + */ + get phrases() { + if (this._phrases) return this._phrases; + return (this._phrases = SearchablePhrase.allFromModel(this.model)); + } + + /** + * Return all searchable words in the model + * @returns {[SearchableWord]} + */ + get words() { + if (this._words) return this._words; + const config = Adapt.course.get('_search'); + const minimumWordLength = config._minimumWordLength; + const ignoreWords = config._ignoreWords; + const phrases = this.phrases; + const indexedSearchableWords = {}; + for (const phrase of phrases) { + for (const word in phrase.words) { + const count = phrase.words[word]; + const searchableWord = (indexedSearchableWords[word] = indexedSearchableWords[word] || new SearchableWord({ word, count: 0, score: 0 })); + searchableWord.count += count; + searchableWord.score = Math.max( + searchableWord.score, + phrase.score + ); + } + } + const words = Object.values(indexedSearchableWords) + .filter(({ word }) => word.length >= minimumWordLength && !ignoreWords.includes(word)); + return (this._words = words); + } + + /** + * Returns all searchable models for the whole course + * @returns {[SearchableModel]} + */ + static get all() { + return Adapt.data.map(model => new SearchableModel({ model })).filter(({ isSearchable }) => isSearchable); + } + +} diff --git a/js/SearchablePhrase.js b/js/SearchablePhrase.js new file mode 100644 index 0000000..0357b16 --- /dev/null +++ b/js/SearchablePhrase.js @@ -0,0 +1,81 @@ +import Adapt from 'core/js/adapt'; +import WORD_CHARACTERS from './WORD_CHARACTERS'; + +const matchNotWordBoundaries = new RegExp(`[${WORD_CHARACTERS}]+`, 'g'); +const trimReplaceNonWordCharacters = new RegExp(`^([^${WORD_CHARACTERS}])+|([^${WORD_CHARACTERS}])+$`, 'g'); + +/** + * Represents a searchable phrase at a model attribute. + */ +export default class SearchablePhrase { + + constructor({ + phrase, + score = null, + level = null, + allowTextPreview = null, + searchAttribute = null + } = {}) { + this.name = searchAttribute?._attributeName ?? null; + this.phrase = phrase; + this.level = level ?? searchAttribute?._level ?? null; + this.score = score ?? (this.level !== null ? (1 / this.level) : null); + this.allowTextPreview = (allowTextPreview ?? searchAttribute?._allowTextPreview) ?? null; + const config = Adapt.course.get('_search'); + // Handle _ignoreWords as a special case to support the authoring tool + const ignoreWords = Array.isArray(config._ignoreWords) + ? config._ignoreWords + : config._ignoreWords.split(','); + const minimumWordLength = config._minimumWordLength; + this.words = this.phrase + .match(matchNotWordBoundaries) + .map(chunk => chunk.replace(trimReplaceNonWordCharacters, '')) + .filter(word => word.length >= minimumWordLength) + .reduce((wordCounts, word) => { + word = word.toLowerCase(); + if (ignoreWords.includes(word)) return wordCounts; + wordCounts[word] = wordCounts[word] || 0; + wordCounts[word]++; + return wordCounts; + }, {}); + } + + /** + * Returns all searchable phrases from the given model. + * @param {Backbone.Model} model + * @returns {[SearchablePhrase]} + */ + static allFromModel(model) { + const htmlToText = html => $(`
${html.trim()}
`).text().trim(); + const searchAttributes = Adapt.course.get('_search')._searchAttributes; + const searchablePhrases = []; + const processValue = (value, searchAttribute, level) => { + if (typeof value === 'object') return _recursivelyCollectPhrases(value, searchAttribute._level); + if (typeof value !== 'string') return; + const phrase = htmlToText(value); + if (!phrase) return; + searchablePhrases.push(new SearchablePhrase({ + phrase, + level, + searchAttribute + })); + }; + const _recursivelyCollectPhrases = (json = model.toJSON(), level = 1) => { + for (const searchAttribute of searchAttributes) { + const attributeName = searchAttribute._attributeName; + const attributeValue = json[attributeName]; + if (!attributeValue) continue; + if (Array.isArray(attributeValue)) { + for (const attributeValueItem of attributeValue) { + processValue(attributeValueItem, searchAttribute, level); + } + continue; + } + processValue(attributeValue, searchAttribute, level); + } + }; + _recursivelyCollectPhrases(); + return searchablePhrases; + } + +} diff --git a/js/SearchableWord.js b/js/SearchableWord.js new file mode 100644 index 0000000..78399fd --- /dev/null +++ b/js/SearchableWord.js @@ -0,0 +1,16 @@ +/** + * Keeps track of the word occurrences and their highest phrase score. + */ +export default class SearchableWord { + + constructor({ + word, + score, + count + } = {}) { + this.word = word; + this.score = score; + this.count = count; + } + +} diff --git a/js/Searcher.js b/js/Searcher.js new file mode 100644 index 0000000..0c40996 --- /dev/null +++ b/js/Searcher.js @@ -0,0 +1,78 @@ +import Adapt from 'core/js/adapt'; +import SearchableModel from './SearchableModel'; +import SearchablePhrase from './SearchablePhrase'; +import SearchObject from './SearchObject'; +import SearchResult from './SearchResult'; +import escapeRegExp from './escapeRegExp'; + +/** + * Main API + * The last instance is available through window.search + */ +export default class Searcher { + + constructor() { + this.searchableModels = SearchableModel.all; + this.wordIndex = this.searchableModels.reduce((wordIndex, searchableModel) => { + searchableModel.words.forEach(({ word }) => { + wordIndex[word] = wordIndex[word] || []; + wordIndex[word].push(searchableModel); + }); + return wordIndex; + }, {}); + } + + /** + * Returns an array of search results. + * @param {string} searchPhrase + * @returns {[SearchResult]} + */ + query(searchPhrase) { + const config = Adapt.course.get('_search'); + const wordIndex = this.wordIndex; + const scoreQualificationThreshold = config._scoreQualificationThreshold; + const matchOn = config._matchOn || {}; + const findWords = new SearchablePhrase({ phrase: searchPhrase }).words; + const indexedSearchResults = {}; + for (const findWord in findWords) { + for (const indexWord in wordIndex) { + // allow only start matches on findWord beginning with indexWord i.e. find: oneness begins with index: one + const rIndexWordBegins = new RegExp('^' + escapeRegExp(indexWord), 'g'); + // allow all matches on indexWord containing findWord i.e. index: someone contains find: one, index: anti-money contains find: money + const rFindWordContains = new RegExp(escapeRegExp(findWord), 'g'); + // allow only start matches on indexWord beginning with findWord i.e. find: one begins index: oneness + const rFindWordBegins = new RegExp('^' + escapeRegExp(findWord), 'g'); + const isIndexBeginsMatch = matchOn._contentWordBeginsPhraseWord === false ? false : rIndexWordBegins.test(findWord); + const isFindContainsMatch = matchOn._contentWordContainsPhraseWord === false ? false : rFindWordContains.test(indexWord); + const isFindBeginsMatch = matchOn._phraseWordBeginsContentWord === false ? false : rFindWordBegins.test(indexWord); + const isFullMatch = matchOn._contentWordEqualsPhraseWord === false ? false : (findWord === indexWord); + const isPartMatch = isIndexBeginsMatch || isFindContainsMatch || isFindBeginsMatch; + if (!isFullMatch && !isPartMatch) continue; + const partMatchRatio = isFullMatch ? 1 : (findWord.length > indexWord.length) ? indexWord.length / findWord.length : findWord.length / indexWord.length; + wordIndex[indexWord].forEach(searchableModel => { + const id = searchableModel.model.get('_id'); + indexedSearchResults[id] = (indexedSearchResults[id] || new SearchResult({ searchableModel })); + indexedSearchResults[id].addFoundWord(indexWord, isFullMatch, partMatchRatio); + }); + } + } + const searchResults = Object.values(indexedSearchResults); + const qualifyingScoreThreshold = 1 / scoreQualificationThreshold; + const qualifyingSearchResults = searchResults.filter(item => item.score >= qualifyingScoreThreshold).sort((a, b) => b.score - a.score); + return qualifyingSearchResults; + } + + /** + * Returns a search object which represents all of the settings used for the + * search, the search results and the user interface search state. + * @param {string} searchPhrase + * @returns {SearchObject} + */ + search(searchPhrase) { + const config = Adapt.course.get('_search'); + const shouldSearch = (searchPhrase.length >= config._minimumWordLength); + const searchResults = shouldSearch ? this.query(searchPhrase) : []; + return new SearchObject(shouldSearch, searchPhrase, searchResults); + } + +} diff --git a/js/WORD_CHARACTERS.js b/js/WORD_CHARACTERS.js new file mode 100644 index 0000000..5be1927 --- /dev/null +++ b/js/WORD_CHARACTERS.js @@ -0,0 +1,416 @@ +const WORD_CHARACTERS = [ + '\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' +].join(''); + +export default WORD_CHARACTERS; diff --git a/js/adapt-search.js b/js/adapt-search.js index 8edc266..f4f1524 100644 --- a/js/adapt-search.js +++ b/js/adapt-search.js @@ -1,128 +1,104 @@ -/* -* adapt-search -* License - https://github.com/cgkineo/adapt-search/blob/master/LICENSE -* Maintainers - Gavin McMaster -*/ -define([ - 'core/js/adapt', - './searchDrawerItemView', - './searchResultsView', - './search-algorithm' -], function(Adapt, SearchDrawerItemView, SearchResultsView, SearchAlgorithm) { - - var lastSearchQuery = null; - var lastSearchObject = null; - var isSearchShown = false; - - var searchConfigDefault = { - _previewWords: 15, - _previewCharacters: 30, - _showHighlights: true, - _showFoundWords: true, - title: 'Search', - description: 'Type in search words', - placeholder: '', - noResultsMessage: 'Sorry, no results were found', - awaitingResultsMessage: 'Formulating results...' - }; - - Adapt.on('search-algorithm:ready', function() { - Adapt.course.set('_search', _.extend(searchConfigDefault, Adapt.course.get('_search'))); - - var searchConfig = Adapt.course.get('_search'); +import Adapt from 'core/js/adapt'; +import SearchDrawerItemView from './searchDrawerItemView'; +import SearchResultsView from './searchResultsView'; +import SEARCH_DEFAULTS from './SEARCH_DEFAULTS'; +import Searcher from './Searcher'; + +class Search extends Backbone.Controller { + + initialize() { + this.isSetup = false; + this.lastSearchQuery = null; + this.lastSearchObject = null; + this.listenTo(Adapt, { + 'app:dataReady': this.onDataReady, + 'app:languageChanged': this.clearSearchResults, + 'search:query': this.query, + 'resources:showSearch': this.onShowSearch, + 'drawer:openedItemView search:draw': this.onOpenedItemView, + 'drawer:closed': this.onDrawerClosed, + 'drawer:noItems': this.onNoItems + }); + } + + onDataReady() { + const config = Adapt.course.get('_search'); + if (!config || config._isEnabled === false) return; + this.setupConfig(); + } + + setupConfig() { + const model = Adapt.course.get('_search') || {}; + const modelWithDefaults = $.extend(true, {}, SEARCH_DEFAULTS, model); + Adapt.course.set('_search', modelWithDefaults); + this.isSetup = true; + window.search = this.searcher = new Searcher(); + } + + clearSearchResults() { + this.query(''); + this.addDrawerItem(); + if (!this.isSetup) return; + window.search = this.searcher = new Searcher(); + } + + addDrawerItem() { + const searchConfig = Adapt.course.get('_search'); searchConfig.title = searchConfig.title || 'Search'; searchConfig.description = searchConfig.description || 'Description'; - - var drawerObject = { + const drawerObject = { title: searchConfig.title, description: searchConfig.description, className: 'is-search', drawerOrder: searchConfig._drawerOrder || 0 }; - Adapt.drawer.addItem(drawerObject, 'resources:showSearch'); - }); - - Adapt.on('resources:showSearch', function() { - if (isSearchShown) return; - - var searchConfig = Adapt.course.get('_search'); - searchConfig = new Backbone.Model(searchConfig); - - var template = Handlebars.templates['searchSingleItem']; - var $element = $(template(searchConfig.toJSON())); + } + onShowSearch() { + if (this.isSearchShown) return; + const searchConfig = Adapt.course.get('_search'); + const template = Handlebars.templates.searchSingleItem; + const $element = $(template(searchConfig)); Adapt.drawer.triggerCustomView($element, true); - Adapt.trigger('search:draw'); - }); - - Adapt.on('drawer:openedItemView search:draw', function() { - isSearchShown = true; - - var searchConfig = Adapt.course.get('_search'); - searchConfig = new Backbone.Model(searchConfig); - - var $searchDrawerButton = $('.is-search'); + } + onOpenedItemView() { + this.isSearchShown = true; + const searchConfigModel = new Backbone.Model(Adapt.course.get('_search')); + const $searchDrawerButton = $('.is-search'); if ($searchDrawerButton.is(':not(div)')) { - var $replacementButton = $('
'); + const $replacementButton = $('
'); $replacementButton.attr('class', $searchDrawerButton.attr('class')); $searchDrawerButton.children().appendTo($replacementButton); $searchDrawerButton.replaceWith($replacementButton); } - - if (lastSearchObject && lastSearchObject.searchResults && lastSearchObject.searchResults.length === 0) { - lastSearchObject = null; - lastSearchQuery = null; - } - - $('.drawer__holder .is-search').append(new SearchDrawerItemView({ model: searchConfig, query: lastSearchQuery }).el); - $('.drawer__holder .is-search').append(new SearchResultsView({ model: searchConfig, searchObject: lastSearchObject }).el); - }); - - Adapt.on('drawer:closed', function() { - isSearchShown = false; - }); - - Adapt.on('search:filterTerms', function(query) { - var searchConfig = Adapt.course.get('_search'); - var searchObject; - - lastSearchQuery = query; - - if (query.length === 0) { - searchObject = _.extend({}, searchConfig, { - query: query, - searchResults: [], - isAwaitingResults: false, - isBlank: true - }); - } else if (query.length < searchConfig._minimumWordLength) { - searchObject = _.extend({}, searchConfig, { - query: query, - searchResults: [], - isAwaitingResults: true, - isBlank: false - }); - } else { - var results = SearchAlgorithm.find(query); - - searchObject = _.extend({}, searchConfig, { - query: query, - searchResults: results, - isAwaitingResults: false, - isBlank: false - }); + if (this.lastSearchObject && this.lastSearchObject.searchResults && this.lastSearchObject.searchResults.length === 0) { + this.lastSearchObject = null; + this.lastSearchQuery = null; } - - lastSearchObject = searchObject; - - Adapt.trigger('search:termsFiltered', searchObject); - }); - - Adapt.once('drawer:noItems', function() { + $('.drawer__holder .is-search') + .append(new SearchDrawerItemView({ model: searchConfigModel, query: this.lastSearchQuery }).el) + .append(new SearchResultsView({ model: searchConfigModel, searchObject: this.lastSearchObject }).el); + } + + onDrawerClosed() { + this.isSearchShown = false; + } + + query(query) { + if (!this.isSetup) return; + this.lastSearchQuery = query; + const searchObject = this.searcher.search(query); + this.lastSearchObject = searchObject; + Adapt.trigger('search:queried', searchObject); + } + + onNoItems() { $('.nav-drawer-btn').removeClass('u-display-none'); - }); + } + +} -}); +export default (Adapt.search = new Search()); diff --git a/js/escapeRegExp.js b/js/escapeRegExp.js new file mode 100644 index 0000000..b3d33f3 --- /dev/null +++ b/js/escapeRegExp.js @@ -0,0 +1,3 @@ +export default function escapeRegExp (str) { + return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); +}; diff --git a/js/search-algorithm.js b/js/search-algorithm.js deleted file mode 100644 index de01193..0000000 --- a/js/search-algorithm.js +++ /dev/null @@ -1,951 +0,0 @@ -define([ - 'core/js/adapt' -], function (Adapt) { - - 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'); - var displayTitle; - var 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) { - const config = model.get('_search'); - if (config && config._isEnabled === false) return false; - - const isAvailableInPage = model.getAncestorModels(true).every(model => model.get('_isAvailable')); - if (!isAvailableInPage) return false; - - const trail = model.getAncestorModels(true); - const firstDisabledTrailItem = _.find(trail, function(item) { - const 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.getAncestorModels(true); - var config = model.get('_search'); - if (config && config._isEnabled === false) return false; - if (model.get('_isLocked')) return false; - - var firstDisabledTrailItem = _.find(trail, 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 2619098..c58b181 100644 --- a/js/searchDrawerItemView.js +++ b/js/searchDrawerItemView.js @@ -1,46 +1,37 @@ -define([ - 'core/js/adapt' -], function(Adapt) { - - var SearchDrawerItemView = Backbone.View.extend({ - - className: 'search', - - events: { - 'click .js-search-textbox-change': 'search', - 'keyup .js-search-textbox-change': 'search' - }, - - initialize: function(options) { - - this.listenTo(Adapt, 'drawer:empty', this.remove); - this.render(); - - this.search = _.debounce(this.search.bind(this), 1000); - if (options.query) { - this.$('.js-search-textbox-change').val(options.query); - } - - }, - - render: function() { - var data = this.model.toJSON(); - - var template = Handlebars.templates['searchBox']; - $(this.el).html(template(data)); - - return this; - }, - - search: function(event) { - if (event && event.preventDefault) event.preventDefault(); - - var searchVal = this.$('.js-search-textbox-change').val(); - Adapt.trigger('search:filterTerms', searchVal); - } - - }); - - return SearchDrawerItemView; - -}); +import Adapt from 'core/js/adapt'; + +export default class SearchDrawerItemView extends Backbone.View { + + className() { + return 'search'; + } + + events() { + return { + 'click .js-search-textbox-change': this.onSearch, + 'keyup .js-search-textbox-change': this.onSearch + }; + } + + initialize(options) { + this.listenTo(Adapt, 'drawer:empty', this.remove); + this.render(); + this.search = _.debounce(this.onSearch.bind(this), 1000); + if (!options.query) return; + this.$('.js-search-textbox-change').val(options.query); + } + + render() { + const data = this.model.toJSON(); + const template = Handlebars.templates.searchBox; + this.$el.html(template(data)); + return this; + } + + onSearch(event) { + if (event && event.preventDefault) event.preventDefault(); + const query = this.$('.js-search-textbox-change').val(); + Adapt.trigger('search:query', query); + } + +} diff --git a/js/searchResultsView.js b/js/searchResultsView.js index e799f76..701e39b 100644 --- a/js/searchResultsView.js +++ b/js/searchResultsView.js @@ -1,195 +1,158 @@ -define([ - 'core/js/adapt', - './search-algorithm' -], function(Adapt, SearchAlgorithm) { - var replaceTagsRegEx = /\<{1}[^\>]+\>/g; - var replaceEscapedTagsRegEx = /<[^>]+>/g; - var replaceEndTagsRegEx = /\<{1}\/{1}[^\>]+\>/g; - var replaceEscapedEndTagsRegEx = /<\/[^>]+>/g; +import Adapt from 'core/js/adapt'; +import WORD_CHARACTERS from './WORD_CHARACTERS'; +import escapeRegExp from './escapeRegExp'; - var SearchResultsView = Backbone.View.extend({ +export default class SearchResultsView extends Backbone.View { - className: 'search__items-container is-inactive', + className() { + return 'search__items-container is-inactive'; + } - events: { + events() { + return { 'click [data-id]': 'navigateToResultPage' - }, - - initialize: function(options) { - this.listenTo(Adapt, { - 'drawer:empty': this.remove, - 'search:termsFiltered': this.updateResults - }); - - this.render(); - - if (options.searchObject) { - this.updateResults(options.searchObject); + }; + } + + initialize({ searchObject = null } = {}) { + this.listenTo(Adapt, { + 'drawer:empty': this.remove, + 'search:queried': this.updateResults + }); + this.render(); + if (!searchObject) return; + this.updateResults(searchObject); + } + + render() { + const template = Handlebars.templates.searchResults; + $(this.el).html(template()); + return this; + } + + updateResults(searchObject) { + this.$el.removeClass('is-inactive'); + const formattedResults = this.formatResults(searchObject); + this.renderResults(formattedResults); + } + + formatResults(searchObject) { + const stripHTMLAndHandlebars = (text) => { + const replaceTagsRegEx = /<{1}[^>]+>/g; + const replaceEscapedTagsRegEx = /<[^>]+>/g; + const replaceEndTagsRegEx = /<{1}\/{1}[^>]+>/g; + const replaceEscapedEndTagsRegEx = /<\/[^>]+>/g; + const replaceHandlebarsRegEx = /{{[^}}]*/g; + const replaceHandlebarsEndRegEx = /}}/g; + text = $(`${text}`).html(); + return text + .replace(replaceEndTagsRegEx, ' ') + .replace(replaceTagsRegEx, '') + .replace(replaceEscapedEndTagsRegEx, ' ') + .replace(replaceEscapedTagsRegEx, '') + .replace(replaceHandlebarsRegEx, ' ') + .replace(replaceHandlebarsEndRegEx, '') + .trim(); + }; + const checkSkipTitlePhrases = (title, result) => { + return result.foundPhrases.find(foundPhrase => { + const lowerPhrase = stripHTMLAndHandlebars(foundPhrase.phrase).toLowerCase(); + const lowerTitle = title.toLowerCase(); + const isNotTitle = (lowerPhrase !== lowerTitle); + return isNotTitle; + }) || result.foundPhrases[result.foundPhrases.length - 1]; + }; + const makeTextPreview = result => { + const numberOfPreviewCharacters = this.model.get('_previewCharacters'); + const numberOfPreviewWords = this.model.get('_previewWords'); + /** + * Note: This regexp makes no sense but it works, need to find a better way + * of matching multilanguage words, which are sometimes a single character + */ + const bodyPrettify = new RegExp(`(([^${WORD_CHARACTERS}]*[${WORD_CHARACTERS}]{1}){1,${numberOfPreviewWords * 2}}|.{0,${numberOfPreviewCharacters * 2}})`, 'i'); + const title = stripHTMLAndHandlebars(result.model.get('title')) || stripHTMLAndHandlebars(result.model.get('displayTitle')) || 'No title found'; + const body = stripHTMLAndHandlebars(result.model.get('body')) || ''; + const hasNoFoundPhrases = (result.foundPhrases.length === 0); + if (hasNoFoundPhrases) { + const textPreview = body.match(bodyPrettify)[0] + '...'; + return [title, textPreview]; } - }, - - render: function() { - var template = Handlebars.templates['searchResults']; - $(this.el).html(template()); - return this; - }, - - updateResults: function(searchObject) { - this.$el.removeClass('is-inactive'); - var formattedResults = this.formatResults(searchObject); - this.renderResults(formattedResults); - }, - - formatResults: function(searchObject) { - var self = this; - var resultsLimit = Math.min(5, searchObject.searchResults.length); - - var formattedResults = _.map(_.first(searchObject.searchResults, resultsLimit), function(result) { - return self.formatResult(result); - }); - - searchObject.formattedResults = formattedResults; - return searchObject; - }, - - formatResult: function(result, query) { - var foundWords = _.keys(result.foundWords).join(' '); - var title = result.model.get('title'); - var displayTitle = result.model.get('displayTitle'); - var body = result.model.get('body'); - var previewWords = this.model.get('_previewWords'); - var previewCharacters = this.model.get('_previewCharacters'); - var wordCharacters = search._regularExpressions.wordCharacters; - - // trim whitespace - title = title.replace(SearchAlgorithm._regularExpressions.trimReplaceWhitespace, ''); - displayTitle = displayTitle.replace(SearchAlgorithm._regularExpressions.trimReplaceWhitespace, ''); - body = body.replace(SearchAlgorithm._regularExpressions.trimReplaceWhitespace, ''); - - // strip tags - title = this.stripTags(title); - displayTitle = this.stripTags(displayTitle); - body = this.stripTags(body); - - var searchTitle = ''; - var textPreview = ''; - - // select title - if (!title) { - searchTitle = $('
' + displayTitle + '
').text() || 'No title found'; - } else { - searchTitle = $('
' + title + '
').text(); + const foundPhrase = checkSkipTitlePhrases(title, result); + const phrase = stripHTMLAndHandlebars(foundPhrase.phrase); + const lowerPhrase = phrase.toLowerCase(); + const lowerTitle = title.toLowerCase(); + if (lowerPhrase === lowerTitle) { + // if the search phrase and title are the same still + const textPreview = body.match(bodyPrettify)[0] + '...'; + return [title, textPreview]; } - - // select preview text - if (result.foundPhrases.length > 0) { - var finder; - var phrase = result.foundPhrases[0].phrase; - // strip tags - phrase = this.stripTags(phrase); - - var lowerPhrase = phrase.toLowerCase(); - var lowerSearchTitle = searchTitle.toLowerCase(); - - if (lowerPhrase === lowerSearchTitle && result.foundPhrases.length > 1) { - phrase = result.foundPhrases[1].phrase; - // strip tags - phrase = this.stripTags(phrase); - lowerPhrase = phrase.toLowerCase(); - } - - if (lowerPhrase === lowerSearchTitle) { - // if the search phrase and title are the same - finder = new RegExp('(([^' + wordCharacters + ']*[' + wordCharacters + ']{1}){1,' + previewWords + '}|.{0,' + previewCharacters + '})', 'i'); - if (body) { - textPreview = body.match(finder)[0] + '...'; - } - } else { - var wordMap = _.map(result.foundWords, function(count, word) { - return { word: word, count: count }; - }); - _.sortBy(wordMap, function(item) { - return item.count; - }); - var wordIndex = 0; - var wordInPhraseStartPosition = lowerPhrase.indexOf(wordMap[wordIndex].word); - while (wordInPhraseStartPosition === -1) { - wordIndex++; - if (wordIndex === wordMap.length) throw new Error('search: cannot find word in phrase'); - wordInPhraseStartPosition = lowerPhrase.indexOf(wordMap[wordIndex].word); - } - var regex = new RegExp('(([^' + wordCharacters + ']*[' + wordCharacters + ']{1}){1,' + previewWords + '}|.{0,' + previewCharacters + '})' + SearchAlgorithm._regularExpressions.escapeRegExp(wordMap[wordIndex].word) + '(([' + wordCharacters + ']{1}[^' + wordCharacters + ']*){1,' + previewWords + '}|.{0,' + previewCharacters + '})', 'i'); - var snippet = phrase.match(regex)[0]; - var snippetIndexInPhrase = phrase.indexOf(snippet); - if (snippet.length === phrase.length) { - textPreview = snippet; - } else if (snippetIndexInPhrase === 0) { - textPreview = snippet + '...'; - } else if (snippetIndexInPhrase + snippet.length === phrase.length) { - textPreview = '...' + snippet; - } else { - textPreview = '...' + snippet + '...'; - } - } - - } else { - finder = new RegExp('(([^' + wordCharacters + ']*[' + wordCharacters + ']{1}){1,' + previewWords + '}|.{0,' + previewCharacters + '})', 'i'); - if (body) { - textPreview = body.match(finder)[0] + '...'; - } + const word = result + .foundWords + .slice(0) + .sort((a, b) => a.count - b.count) + .find(({ word }) => lowerPhrase.includes(word)) + .word; + /** + * Note: This regexp makes no sense but it works, need to find a better way + * of matching multilanguage words, which are sometimes a single character + */ + const snippetMatcher = new RegExp(`(([^${WORD_CHARACTERS}]*[${WORD_CHARACTERS}]{1}){1,${numberOfPreviewWords}}|.{0,${numberOfPreviewCharacters}})${escapeRegExp(word)}(([${WORD_CHARACTERS}]{1}[^${WORD_CHARACTERS}]*){1,${numberOfPreviewWords}}|.{0,${numberOfPreviewCharacters}})`, 'i'); + const snippet = phrase.match(snippetMatcher)[0]; + const snippetIndexInPhrase = phrase.indexOf(snippet); + const isSnippetAtPhraseStart = (snippetIndexInPhrase === 0); + const isSnippetAtPhraseEnd = (snippetIndexInPhrase + snippet.length === phrase.length); + const textPreview = `${isSnippetAtPhraseStart ? '' : '...'}${snippet}${isSnippetAtPhraseEnd ? '' : '...'}`; + return [title, textPreview]; + }; + const wrapWordsWithSpan = (words, text) => { + const tagWords = words.map(({ word }) => word); + const initial = []; + while (text) { + const lowerCaseText = text.toLowerCase(); + const wordTextPositions = tagWords.map(word => lowerCaseText.indexOf(word)); + const firstWordIndex = wordTextPositions.reduce((firstWordIndex, wordTextPosition, wordIndex) => { + if (wordTextPosition === -1) return firstWordIndex; + if (firstWordIndex === -1) return wordIndex; + const firstWordTextPosition = wordTextPositions[firstWordIndex]; + if (wordTextPosition >= firstWordTextPosition) return firstWordIndex; + return wordIndex; + }, -1); + if (firstWordIndex === -1) break; + const word = tagWords[firstWordIndex]; + const wordTextPosition = wordTextPositions[firstWordIndex]; + initial.push(text.slice(0, wordTextPosition)); + initial.push(`${text.slice(wordTextPosition, wordTextPosition + word.length)}`); + text = text.slice(wordTextPosition + word.length, text.length); } - - var searchTitleTagged = tag(result.foundWords, searchTitle); - var textPreviewTagged = tag(result.foundWords, textPreview); - + initial.push(text); + return initial.join(''); + }; + const resultsLimit = Math.min(5, searchObject.searchResults.length); + const formattedResults = searchObject.searchResults.slice(0, resultsLimit).map(result => { + const [title, textPreview] = makeTextPreview(result); return { - searchTitleTagged: searchTitleTagged, - searchTitle: searchTitle, - foundWords: foundWords, - textPreview: textPreview, - textPreviewTagged: textPreviewTagged, + title, + textPreview, + titleTagged: wrapWordsWithSpan(result.foundWords, title), + textPreviewTagged: wrapWordsWithSpan(result.foundWords, textPreview), + foundWords: result.foundWords.map(({ word }) => word).join(' '), id: result.model.get('_id') }; - - function tag(words, text) { - var initial = ''; - _.each(words, function(count, word) { - var wordPos = text.toLowerCase().indexOf(word); - if (wordPos < 0) return; - initial += text.slice(0, wordPos); - var highlighted = text.slice(wordPos, wordPos + word.length); - initial += "" + highlighted + ''; - text = text.slice(wordPos + word.length, text.length); - }); - initial += text; - return initial; - } - }, - - stripTags: function (text) { - text = $('' + text + '').html(); - return text - .replace(replaceEndTagsRegEx, ' ') - .replace(replaceTagsRegEx, '') - .replace(replaceEscapedEndTagsRegEx, ' ') - .replace(replaceEscapedTagsRegEx, ''); - }, - - renderResults: function(results) { - var template = Handlebars.templates['searchResultsContent']; - this.$('.search__items-container-inner').html(template(results)); - }, - - navigateToResultPage: function(event) { - event && event.preventDefault(); - var blockID = $(event.currentTarget).attr('data-id'); - - Adapt.navigateToElement('.' + blockID); - Adapt.trigger('drawer:closeDrawer'); - } - - }); - - return SearchResultsView; - -}); + }); + searchObject.formattedResults = formattedResults; + return searchObject; + } + + renderResults(results) { + const template = Handlebars.templates.searchResultsContent; + this.$('.search__items-container-inner').html(template(results)); + } + + navigateToResultPage(event) { + event && event.preventDefault(); + const blockID = $(event.currentTarget).attr('data-id'); + Adapt.navigateToElement('.' + blockID); + Adapt.trigger('drawer:closeDrawer'); + } + +} diff --git a/less/search.less b/less/search.less index 4b005ac..db17358 100755 --- a/less/search.less +++ b/less/search.less @@ -19,7 +19,13 @@ &__items-container { border-top: 1px solid @drawer-item-hover; + + .is-found { + background: yellow; + color: black; + } } + } // -------------------------------------------------- diff --git a/less/searchItem.less b/less/searchItem.less index 4e2d455..cce1940 100644 --- a/less/searchItem.less +++ b/less/searchItem.less @@ -1,8 +1,17 @@ .search { &__item.no-results { - padding: @item-padding; + padding: @item-padding + 1em; line-height: @title-line-height; border-bottom: 1px solid @drawer-item-hover; color: @drawer-item-inverted; } + + &__item.drawer__item-btn { + padding-left: @item-padding + 1em; + } + + &__item-foundwords.drawer__item-body { + padding-top: 0.2em; + font-size: 0.8em; + } } diff --git a/templates/searchResultsContent.hbs b/templates/searchResultsContent.hbs index d1d1a32..7081e37 100644 --- a/templates/searchResultsContent.hbs +++ b/templates/searchResultsContent.hbs @@ -12,15 +12,15 @@ {{else}} {{#each formattedResults}} -