-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
657b6db
commit 4eb37d6
Showing
18 changed files
with
1,143 additions
and
1,298 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 => $(`<div>${html.trim()}</div>`).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; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
|
||
} |
Oops, something went wrong.