helpers.js

/**
 * Helper methods.
 * @namespace Helpers
 */

/**
 * Custom async forEach function taken from p-iteration
 *
 * @private
 * @memberof Helpers
 * @see {@link https://toniov.github.io/p-iteration/global.html#forEach|p-iteration}
 * @param  {Promise[]} array - array of promises, executed concurrently
 * @param  {Function} callback - the callback function called using the resolved value of each promise
 * @param  {Object} [thisArg] - optional this context
 * @returns {Promise} A promise that resolves only after all other promises are done
 */
/* istanbul ignore next */
async function _asyncForEach (array, callback, thisArg) {
  const promiseArray = []

  for (let i = 0; i < array.length; i++) {
    if (i in array) {
      const p = Promise.resolve(array[i]).then((currentValue) => {
        return callback.call(thisArg || this, currentValue, i, array)
      })
      promiseArray.push(p)
    }
  }
  await Promise.all(promiseArray)
}

/**
 * Blink target node N times over 3/4 of a second by adding and removing color class
 *
 * @memberof Helpers
 * @param  {Node} node - the node we will add and remove the color class from
 * @param  {number} times - the amount of times to blink the node. Must be an integer.
 */
function blinkNode (node, times) {
  const interval = 750 / (times * 2)

  // removing the color class first ensures we change color the correct amount of times
  node.classList.remove('color')

  const tempInterval = setInterval(() => {
    node.classList.contains('color')
      ? node.classList.remove('color')
      : node.classList.add('color')
  }, interval)

  setTimeout(() => {
    clearInterval(tempInterval)
    // we cannot reliably enter this if statement - it's a bit of defensive programming to ensure
    // the highlight color class is always added back after blinking the node
    /* istanbul ignore next */
    if (!node.classList.contains('color')) node.classList.add('color')
  }, 750)
}

/**
 * Adds Multidict add/remove context menu items for when user selects text
 *
 * @memberof Helpers
 */
function createMenuItems () {
  browser.menus.create({
    id: 'addCustomWord',
    type: 'normal',
    title: 'Add word to personal dictionary',
    contexts: ['selection'],
    icons: { 16: 'media/icons/plus-icon.svg' }
  })

  browser.menus.create({
    id: 'removeCustomWord',
    type: 'normal',
    title: 'Remove word from personal dictionary',
    contexts: ['selection'],
    icons: { 16: 'media/icons/minus-icon.svg' }
  })
}

/**
 * A debounced function is a function that will delay the execution of the inner (callback) function
 * by a certain amount of time each time the debounced (returned) function is called. In our case,
 * we don't want to spellcheck text every time the user presses a key, so we debounce the spellcheck
 * function with each keypress. This way each keypress will delay the spellcheck logic by {wait},
 * which means we will only spellcheck text after the user has stopped typing.
 *
 * @memberof Helpers
 * @constructor
 * @param  {Function} callback - the callback function to be executed after wait expires
 * @param  {number} wait - the amount of time, in milliseconds, to debounce the function
 * @returns {Function} - the function to be executed after wait
 */
function debounce (callback, wait) {
  if (typeof callback !== 'function') throw new TypeError('First argument to debounce must be of type function')
  let timeout
  return (...args) => {
    clearTimeout(timeout)
    timeout = setTimeout(function () { callback.apply(this, args) }, wait)
  }
}

/**
 * Checks if a node has any children.
 *
 * @private
 * @memberof Helpers
 * @param  {Node} node - the node to check
 * @returns {Boolean} True if the node has any children
 */
function _hasChildNodes (node) {
  return (typeof node === 'object') &&
    (typeof node.childNodes !== 'undefined') &&
    (node.childNodes.length > 0)
}

/**
 * Takes a node or nodeList and recursively flattens the node(s) and all children into an array
 *
 * @memberof Helpers
 * @param  {Node|Node[]|NodeList} nodeList - the nodeList, node, or array of nodes to operate on
 * @param  {Array} [accumulator=[]] - array used to accumulate all child nodes (defaults to empty)
 * @returns {Array} Flattened array of node(s) and all child nodes
 */
function flattenAllChildren (nodeList, accumulator = []) {
  if (typeof nodeList.nodeType === 'number') nodeList = [nodeList]
  if (!Array.isArray(nodeList)) nodeList = Array.from(nodeList)

  nodeList.forEach((node) => {
    accumulator.push(node)
    if (_hasChildNodes(node)) {
      flattenAllChildren(node.childNodes, accumulator)
    }
  })
  return accumulator
}

/**
 * Returns an array of languages based on getAcceptLanguages and getUILanguage to use as defaults
 * for when no saved languages exist in browser storage.
 *
 * @memberof Helpers
 * @returns {array} Array of language codes i.e. ['en-US', 'fr']
 */
async function getDefaultLanguages () {
  const acceptedLanguages = await browser.i18n.getAcceptLanguages()
  const uiLanguage = browser.i18n.getUILanguage()

  return [uiLanguage].concat(acceptedLanguages)
}

/**
 * Return a boolean value that is true if the node is a supported editable node
 *
 * @memberof Helpers
 * @param  {node} node - the node to be checked
 * @returns {boolean} True if the node is a text area
 */
function isSupported (node) {
  return node.nodeName === 'TEXTAREA'
}

/**
 * Loads local dictionary files from supported languages
 *
 * @memberof Helpers
 * @param  {array} languages - array of all lowercase 5 digit language codes: ['de-de', 'en-au']
 * @returns {Promise} Promise resolves array of dictionary objects: [{ 'de-de', dic, aff }, { 'en-au', dic, aff }]
 */
/* istanbul ignore next */
function loadDictionaries (languages) {
  // ToDo: check if this negatively impacts memory imprint (may need to fetch dict/aff files)
  const dicts = []
  return _asyncForEach(languages, async (language) => {
    const dic = await _readTextFile(browser.runtime.getURL(`./dictionaries/${language}.dic`))
    const aff = await _readTextFile(browser.runtime.getURL(`./dictionaries/${language}.aff`))
    if (!dic.error && !aff.error) {
      dicts.push({ language, dic, aff })
    }
  }).then(() => dicts)
}

/**
 * Creates a notification that is displayed to the user
 *
 * @memberof Helpers
 * @param  {type} title - the notification title
 * @param  {type} message - the message that will appear as the notification body
 */
function notify (title, message) {
  browser.notifications.create(title, {
    type: 'basic',
    iconUrl: browser.runtime.getURL('media/icons/icon-64.png'),
    title,
    message
  })
}

/**
 * Prepares a language array from an array of language codes
 *
 * @memberof Helpers
 * @param  {string[]} languageCodes - array of langauge codes i.e. ['de-DE', 'en-AU', 'en', 'fr']
 * @returns {string[]} Array of normalised language codes i.e. ['de-de', 'en-au', 'en-en', 'fr-fr']
 */
function prepareLanguages (languageCodes) {
  return languageCodes.reduce((acc, language, index) => {
    // if we come across a language code without a locale, use the language code as the locale
    if (language.length === 2) {
      language += `-${language}`
      languageCodes[index] = language
    }
    // this prevents adding duplicate languageCodes to language array
    if (languageCodes.indexOf(language) === index) {
      return [...acc, language.toLowerCase()]
    }
    return acc
  }, [])
}

/**
 * Reads a text file, the Firefox web-extension way. Returns a promise
 *
 * @private
 * @memberof Helpers
 * @param  {string} path - the path from which to read the text file
 * @returns {Promise} Promise resolves with the entire text read from the text file
 */
/* istanbul ignore next */
function _readTextFile (path) {
  return new Promise((resolve, reject) => {
    fetch(path, { mode: 'same-origin' })
      .then(function (res) {
        return res.blob()
      })
      .then(function (blob) {
        const reader = new FileReader()

        reader.addEventListener('loadend', function () {
          resolve(this.result)
        })

        reader.readAsText(blob)
      })
      .catch(error => {
        resolve({ error: error })
      })
  })
}

/**
 * Takes a node, array of nodes, or nodeList and object of attribute key value pairs to set on the
 * given nodes
 *
 * @memberof Helpers
 * @param  {(node|node[]|nodeList)} nodeList - the nodeList, node, or array of nodes to operate on
 * @param  {object} attributes - object of attribute key value pairs
 */
function setNodeListAttributes (nodeList, attributes) {
  if (typeof nodeList.nodeType === 'number') nodeList = [nodeList]
  if (!Array.isArray(nodeList)) nodeList = Array.from(nodeList)

  nodeList.forEach(node =>
    Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, value)))
}

module.exports = {
  blinkNode,
  createMenuItems,
  debounce,
  flattenAllChildren,
  getDefaultLanguages,
  isSupported,
  loadDictionaries,
  notify,
  prepareLanguages,
  setNodeListAttributes
}