Skip to content

Commit

Permalink
issue/55 Refactor (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
oliverfoster authored Jul 15, 2021
1 parent 657b6db commit 4eb37d6
Show file tree
Hide file tree
Showing 18 changed files with 1,143 additions and 1,298 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="https://community.adaptlearning.org/" target="_blank"><img src="https://github.com/adaptlearning/documentation/blob/master/04_wiki_assets/plug-ins/images/adapt-logo-mrgn-lft.jpg" alt="adapt learning logo" align="right"></a>
**Framework versions:** 5.5+
**Version number:** 4.0.0 <a href="https://community.adaptlearning.org/" target="_blank"><img src="https://github.com/adaptlearning/documentation/blob/master/04_wiki_assets/plug-ins/images/adapt-logo-mrgn-lft.jpg" alt="adapt learning logo" align="right"></a>
**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
Expand Down
4 changes: 2 additions & 2 deletions bower.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions js/SEARCH_DEFAULTS.js
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;
19 changes: 19 additions & 0 deletions js/SearchObject.js
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)
});
}

}
50 changes: 50 additions & 0 deletions js/SearchResult.js
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);
}
}

}
83 changes: 83 additions & 0 deletions js/SearchableModel.js
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);
}

}
81 changes: 81 additions & 0 deletions js/SearchablePhrase.js
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;
}

}
16 changes: 16 additions & 0 deletions js/SearchableWord.js
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;
}

}
Loading

0 comments on commit 4eb37d6

Please sign in to comment.