const nspell = require('nspell')
const { cleanText, getRelativeBounds } = require('./text-methods')
/** Class representing a list of custom words. A CustomWordList is a dummy class that helps to
* iterate over and perform operations on the user's custom words which are loaded from browser
* storage.
*/
class CustomWordList {
/**
* Create a CustomWordList. Words are sorted alphabetically using the provided locale on creation
*
* @param {WordList|string[]} wordList - an array of custom words or object of words and languages
* @param {string} locale - the locale (i.e. 'en' or 'de') used to sort the custom words
*/
constructor (wordList, locale) {
this._wordList = this._generateWordList(wordList)
this.sort(locale)
}
/**
* Adds a word to the CustomWordList. Note that adding a word does not call sort().
*
* @param {string} word - word to add
* @param {string[]} languages - the languages the word the word is misspelt in
*/
add (word, languages) {
this._wordList[word] = languages
this._words.push(word)
}
/**
* Removes a word from the CustomWordList
*
* @param {string} word - word to be remove
*/
remove (word) {
delete this._wordList[word]
this._words.remove(word)
}
/**
* Get all custom words
*
* @returns {string[]} The custom words only (no languages)
*/
get words () {
return this._words
}
/**
* Get an unsorted wordList object that includes each word and the languages it is misspelt in
*
* @returns {WordList} Object of words and languages
*/
get wordList () {
return this._wordList
}
/**
* Sort all custom words alphabetically based on a specific locale
*
* @param {string} locale - a two character length language string (i.e. 'de' or 'es')
*/
sort (locale) {
this._words = Object.keys(this._wordList).sort((a, b) => a.localeCompare(b, locale))
}
/**
* A WordList object has misspelt words and the languages they are misspelt in.
*
* @typedef {Object} WordList
* @see CustomWordList
* @property {string[]} misspeltWord - array of languages the word is misspelt in
*/
/**
* Convert original style wordList (array of strings) into object of arrays. Ensures backwards
* compatibility after breaking changes (so users don't lose custom words)
*
* @private
* @param {WordList|string[]} wordList - object or array of strings
* @returns {WordList} A WordList
*/
_generateWordList (wordList) {
if (typeof wordList === 'object' && !Array.isArray(wordList)) return wordList
const wordListObject = {}
wordList.forEach(word => { wordListObject[word] = [] })
return wordListObject
}
}
/**
* A Dictionary class represents a language, a dictionary of valid words in that language, and a
* rule set (aff file) that defines valid word forms (i.e. plurals, capitilisation, etc)
*
* @see User
* @see {@link https://github.com/wooorm/nspell#nspellaff-dic|NSpell Dictionary Object}
*/
class Dictionary {
/**
* Create a Dictionary class from a dictionary object
*
* @param {object} dictionary - a dictionary object
* @property {string} dictionary.language - a fully formed language code i.e. 'en-au' or 'de-de'
* @property {string} dictionary.dic - a complete string representation of a dic file
* @property {string} dictionary.aff - a complete string representation of an aff file
*/
constructor (dictionaryObject) {
this.language = dictionaryObject.language
this.dic = dictionaryObject.dic
this.aff = dictionaryObject.aff
}
// make Dictionary iterable (all values)
[Symbol.iterator] () {
return [this.language, this.dic, this.aff].values()
}
}
/**
* Class representing the spell checked content which contains the raw text, cleaned text, misspelt
* Words, and spelling suggestions according to a specific language (i.e. a single NSpell instance).
* The methods, while documented, are private and should not be called outside class instantiation.
*/
class Spelling {
/**
* Create a Spelling class
*
* @param {NSpell} speller - an nspell instance
* @param {string} content - the content to be spell checked
*/
constructor (speller, content) {
this.content = content
this.speller = speller
this._cleanedText = cleanText(content)
this.misspeltStrings = this._generateStrings()
this.misspeltWords = this._generateWords()
this.suggestions = this._generateSuggestions()
}
/**
* Generates misspelt strings. Should only be called during class instantiation.
*
* @returns {string[]} An array of misspelt strings
*/
_generateStrings () {
return this._cleanedText.filter(word => !this.speller.correct(word))
}
/**
* Generates array of misspelt Words by checking the spelling of each bit of cleaned text.
* Should only be called during class instantiation.
*
* @see Word
* @returns {Word[]} An array of misspelt Words
*/
_generateWords () {
let index = 0
return this.misspeltStrings.map(word => {
index = this.content.indexOf(word, index + word.length)
return new Word(word, ...getRelativeBounds(word, this.content, index))
})
}
/**
* A Suggestions object contains multiple misspelt words as nested objects with the following
* properties.
*
* @typedef {Object} Suggestions
* @property {Object} misspeltWord - a sequence of tokens representing the misspelt word
* @property {string[]} misspeltWord.suggestedWords - an array of string suggestions
* @property {number} misspeltWord.count - the amount of times this exact sequence of tokens
* appears inside the text content
*/
/**
* Generates a Suggestions object. Should only be called during class instantiation.
*
* @returns {Suggestions} A Suggestions object
*/
_generateSuggestions () {
this.suggestions = {}
for (let i = 0; i < this.misspeltWords.length; i++) {
const word = this.misspeltWords[i].text
const suggestedWords = this.speller.suggest(word)
if (this.suggestions[word]) {
this.suggestions[word].count++
} else if (suggestedWords.length > 0) {
this.suggestions[word] = { suggestedWords, count: 1 }
}
}
return this.suggestions
}
}
/**
* Class representing a list of suggestions for a misspelt word. It will keep track of which
* suggestion is currently being shown to the user.
*/
class SuggestionTracker {
/**
* Create a SuggestionTracker
*
* @param {string[]} suggestions - array of suggestion strings
*/
constructor (suggestions) {
this.suggestions = suggestions
this._suggestionIndex = suggestions.length - 1
}
/**
* Get the currently shown/inlined suggestion
*
* @returns {string} The currently shown/inlined suggestion
*/
get currentSuggestion () {
return this.suggestions[this._suggestionIndex]
}
/**
* Cycle through suggestions in a given direction
*
* @param {string} direction - either 'up' or 'down'
*/
cycle (direction) {
direction === 'up' ? this._suggestionIndex++ : this._suggestionIndex--
if (this._suggestionIndex === this.suggestions.length) {
this._suggestionIndex = 0
}
if (this._suggestionIndex < 0) {
this._suggestionIndex = this.suggestions.length - 1
}
}
}
/**
* Class representing a user. A User has dictionaries, spelling instances, and custom/own words.
*/
class User {
/**
* Create a User
*
* @param {object[]} dictionaries - array of dictionary objects
* @param {string[]} languages - array of five digit language codes
* @param {object[]} ownWords - array of user saved custom word objects
*/
constructor (dictionaries, languages, ownWords) {
this._dicts = this._createDictionaries(dictionaries) // dictionary objects [{ language: 'en-au', dic: '', aff: '' }]
this._langs = languages // language strings ['en-au', 'de-de', 'en-gb']
this._ownWords = ownWords // array of user saved custom words
this._spellers = this._createSpellers() // nspell instances by language { language: nspell }
}
/**
* Gets the user's dictionaries
* @returns {Dictionary[]} - array of user Dictionaries
*/
get dicts () {
return this._dicts
}
/**
* Gets the user's languages. These are ordered by language preference.
* @returns {string[]} - array of language codes
*/
get langs () {
return this._langs
}
/**
* Gets the user's nspell (Speller) instances
* @see {@link https://github.com/wooorm/nspell#table-of-contents|NSpell}
* @returns {nspell[]} An array of nspell instances (Spellers)
*/
get spellers () {
return this._spellers
}
/**
* Gets the user's preferred (or default) language when spell checking content
* @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/i18n/LanguageCode|Language Code}
* @param {string} contentLanguage - two character length locale i.e. 'de' or 'au'
* @returns {string} - a five character length language code i.e. 'en-au' or 'de-de'
*/
getPreferredLanguage (contentLanguage) {
for (const language of this._langs) {
const locale = language.substr(3, 5)
if (this._langs.includes(`${contentLanguage}-${locale}`)) {
return `${contentLanguage}-${locale}`
}
}
// default to first preferred language if no match found
return `${this._langs[0]}`
}
/**
* Sets the preferred language order of a user based on a sorted array of languages
* @param {string[]} languages - array of language codes i.e. ['de-de', 'en-au']
*/
setPreferredLanguageOrder (languages) {
const newLangs = []
for (let i = 0; i < languages.length; i++) {
if (this._langs.includes(languages[i])) {
newLangs.push(languages[i])
}
}
this._langs = newLangs
}
/**
* Adds a word to user's custom/own words and updates existing spellchecker instances
*
* @param {string} word - word string to be added
*/
addWord (word) {
const misspeltLangs = []
this._langs.forEach(language => {
const speller = this._spellers[language]
if (!speller.correct(word)) {
speller.add(word)
misspeltLangs.push(language)
if (!speller.correct(word)) this._fixWord(word, speller, language)
}
})
if (misspeltLangs.length > 0) { this._ownWords[word] = misspeltLangs }
}
/**
* Removes a word from user's custom/own words and updates existing spellchecker instances
*
* @param {string} word - word string to be removed
*/
removeWord (word) {
// users may attempt (using shortcuts) to remove words that are not in their custom word list
// which would result in them being here undefined
if (!this._ownWords[word]) return
this._ownWords[word].forEach(language => {
this._spellers[language].remove(word)
})
delete this._ownWords[word]
}
// private method for working around this bug: https://github.com/wooorm/nspell/issues/25
_fixWord (word, speller, language) {
console.warn(`Multidict: fixing word ${word} to be correct in ${language}`)
speller.remove(word)
speller.add(word)
}
// _createSpellers should only ever be called during class instantiation - creates nspell instances
_createSpellers () {
if (this._langs.length !== this._dicts.length) {
throw new RangeError('Languages and user dictionary length must be equal. Aborting.')
}
this._spellers = {}
for (let i = 0; i < this._dicts.length; i++) {
this._spellers[this._langs[i]] = nspell(this._dicts[i])
}
if (Object.keys(this._ownWords).length > 0) {
Object.keys(this._ownWords).forEach((word) => { this.addWord(word) })
}
return this._spellers
}
// _createDictionaries should only ever be called during class instantiation - creates Dictionary instances
_createDictionaries (dictionaries) {
const dicts = []
dictionaries.forEach(dictionaryObject => {
dicts.push(new Dictionary(dictionaryObject))
})
return dicts
}
}
/** A Word is an iterable class that contains some text, its length, and relative word boundaries.
* Iterating over a Word will yield the word itself followed by the start and then end values.
*/
class Word {
/**
* Create a Word
*
* @param {string} word - the string we will be using to create the Word
* @param {number} start - the beginning of the word relative to the content it was created from
* @param {number} end - the end of the word relative to the content it was created from
*/
constructor (word, start, end) {
this.text = word
this.start = Number.parseInt(start)
this.end = Number.parseInt(end)
this.length = word.length
}
/**
* Check if the word is valid
*
* @returns {boolean} True if the length of the word is greater than 0
*/
isValid () {
return this.length > 0
}
// make Word iterable (values only)
[Symbol.iterator] () {
return [this.text, this.start, this.end].values()
}
}
module.exports = {
CustomWordList,
Dictionary,
Spelling,
SuggestionTracker,
User,
Word
}