index.js

/**
 * @module reading-data
 * @requires winston
 * @requires lodash.clonedeep
 */

const log = require('winston')
log.add(new log.transports.Console())
const CLONE = require('lodash.clonedeep')
const JP = require('jsonpath')
const TIMESPAN = require('time-span')
const MS = require('pretty-ms')

const ReadingData = (function () {
  // PRIVATE VARIABLES
  /**
   * Object containing default configuration.
   *
   * @memberof module:reading-data
   * @private
   * @type {Object}
   * @prop {Object}  plugins={}     - Holds configuration for installed plugins.
   * @prop {Boolean} preload=false  - Enables/disables preloading of data.
   * @prop {Object}  preloadData={} - Holds data to be preloaded.
   */
  let config = {
    plugins: {},
    preload: false,
    preloadData: {}
  }

  /**
   * Array holding the hooks called during [.run()]{@link module:reading-data~run}.
   *
   * @memberof module:reading-data
   * @private
   * @type {String[]}
   *
   * @since 0.2.0
   */
  let hooks = ['preload', 'fetch', 'process']

  /**
   * Array holding the currently installed plugins.
   *
   * @memberof module:reading-data
   * @private
   * @type {Array}
   */
  let plugins = []

  /**
   * Object holding metadata about this reading-data instance
   *
   * @memberof module:reading-data
   * @private
   * @type {Object}
   *
   * @since 0.8.0
   */
  let meta = {}

  /**
   * Generate a unique ID to identify an installed plugin.
   *
   * @memberof module:reading-data
   * @private
   * @return {Number} A number which hasn’t yet been used to identify a plugin.
   */
  let generatePluginID = function () {
    if (!this.count) this.count = 0
    let id = this.count += 1
    return id
  }

  /**
   * Load [config.preloadData]{@link module:reading-data~config} into
   * [.data]{@link module:reading-data~data}.
   *
   * @memberof module:reading-data
   * @private
   * @param {Object} context
   */
  let preload = function (context) {
    if (!config.preload) {
      return
    }
    log.debug('Preloading data...')
    Object.assign(context.data, config.preloadData)
    config.preload = false
  }

  /**
   * Test whether a string starts with '$'.
   *
   * @memberof module:reading-data
   * @private
   * @param  {String} string The string to test.
   * @return {Boolean}       `true` if `string` starts with `$`, otherwise `false`.
   *
   * @since 0.7.0
   */
  let isJSONPath = function (string) {
    return /^\$/.test(string)
  }

  /**
   * Test whether or not a plugin should be called for a given hook and scope.
   *
   * @memberof module:reading-data
   * @private
   * @param  {String} hook         The hook currenty being called.
   * @param  {Object} pluginConfig The configuration object for this plugin.
   * @param  {String} scope        The scope for which the plugin is currently being called.
   * @return {Boolean}             `true` if that plugin should be called for `hook` & `scope`.
   *
   * @since 0.4.0
   */
  let shouldCall = function (hook, pluginConfig, scope) {
    if (!pluginConfig.hasOwnProperty('hooks')) {
      return hook === 'fetch'
    }
    if (typeof pluginConfig.hooks === 'string') {
      return hook === pluginConfig.hooks
    }
    if (typeof pluginConfig.hooks === 'object') {
      if (pluginConfig.hooks.hasOwnProperty(scope)) {
        return hook === pluginConfig.hooks[scope]
      }
      if (pluginConfig.hooks.hasOwnProperty('default')) {
        return hook === pluginConfig.hooks.default
      }
    }
    return false
  }

  /**
   * Call plugins’ methods, and add their returned values to
   * [.data]{@link module:reading-data~data} under the plugin’s scope.
   *
   * @memberof module:reading-data
   * @private
   * @param  {String} hook    Name of the hook to try to call on each plugin.
   * @param  {Object} context Contextual this passed from {@link module:reading-data~run}
   * @return {Object}         context.data after all plugins have returned their data.
   *
   * @since 0.2.0
   */
  let callHook = async function (hook, context) {
    await Promise.all(plugins.map(async plugin => {
      let pluginConfig = config.plugins[plugin.__id__]
      let pluginScopes = Array.isArray(pluginConfig.scope) ? pluginConfig.scope : Array.of(pluginConfig.scope)
      await Promise.all(pluginScopes.map(async scope => {
        if (shouldCall(hook, pluginConfig, scope)) {
          let pluginContext = {
            config: pluginConfig,
            scope: scope
          }
          if (isJSONPath(scope)) {
            let paths = JP.paths(context.data, scope)
            await Promise.all(paths.map(async path => {
              let pathString = JP.stringify(path)
              pluginContext.data = JP.value(context.data, pathString)
              let pluginData = await plugin.data(pluginContext, context)
              if (pathString === '$') {
                context.data = pluginData
              } else {
                JP.value(context.data, pathString, pluginData)
              }
            }))
          } else {
            pluginContext.data = context.data[scope] || {}
            let pluginData = await plugin.data(pluginContext, context)
            context.data[scope] = pluginData
          }
        }
      }))
    }))
    return context.data
  }

  /**
   * Cycle through `.hooks`, calling each hook in order.
   *
   * @memberof module:reading-data
   * @private
   * @param  {Object} context Contextual this passed from {@link module:reading-data~run}
   * @return {Object}         `context.data` after all plugins have returned their data.
   *
   * @since 0.2.0
   */
  let callHooks = async function (context) {
    for (let hook of hooks) {
      await callHook(hook, context)
    }
    return context.data
  }

  return {
    // PUBLIC VARIABLES
    /**
     * Contains the reading data currently available.
     *
     * @type {Object}
     * @since 0.0.1
     */
    data: {},

    /**
     * Get the current configuration.
     * @return {Object} An object containing the current configuration.
     *
     * @since 0.6.0
     */
    config: function () {
      return config
    },

    /**
     * Get an array of the currently installed plugins.
     * @return {Object[]} An array of the currently installed plugins.
     *
     * @since 0.5.0
     */
    plugins: function () {
      return plugins
    },

    /**
     * Get the metadata object for this instance of reading-data.
     * @return {Object} Object containing metadata about reading-data, e.g. how long `.run()` took to complete.
     *
     * @since 0.8.0
     */
    meta: function () {
      return meta
    },

    // PUBLIC FUNCTIONS
    /**
     * Adds a hook to be called during ReadingData’s
     * [.run()]{@link module:reading-data~run} cycle.
     *
     * @param  {String} hook          The name of the new hook to register.
     * @param  {String} [preposition] Either `'before'` or `'after'`.
     * @param  {String} [location]    The name of the existing hook to register the new hook before or after.
     * @return {Object} Returns [ReadingData]{@link module:reading-data} to allow for method chaining.
     *
     * @example <caption>Add a new hook after all existing hooks.</caption>
     * const RD = require('@delucis/reading-data')
     * RD.addHook('postProcess')
     *
     * @example <caption>Add a new hook before all existing hooks.</caption>
     * const RD = require('@delucis/reading-data')
     * RD.addHook('init', 'before')
     *
     * @example <caption>Add a “preProcess” hook before the existing “process” hook.</caption>
     * const RD = require('@delucis/reading-data')
     * RD.addHook('preProcess', 'before', 'process')
     *
     * @since 0.2.0
     */
    addHook: function (hook, preposition, location) {
      if (typeof hook !== 'string') {
        throw new Error('ReadingData#addHook(): first argument must be a string, was ' + typeof hook + '.')
      }
      if (hooks.indexOf(hook) >= 0) {
        log.debug('ReadingData#addHook(): "' + hook + '" is already registered.')
        return this
      }
      if (typeof preposition === 'undefined') {
        hooks.push(hook)
        return this
      }
      if (typeof preposition !== 'string') {
        throw new Error('ReadingData#addHook(): second argument must be a string, was ' + typeof preposition + '.')
      }
      if (preposition !== 'after' && preposition !== 'before') {
        throw new Error('ReadingData#addHook(): second argument must be either "after" or "before", was "' + preposition + '".')
      }
      if (typeof location === 'undefined') {
        if (preposition === 'before') {
          hooks.unshift(hook)
          return this
        } else { // preposition === 'after'
          hooks.push(hook)
          return this
        }
      }
      if (typeof location !== 'string') {
        throw new Error('ReadingData#addHook(): third argument must be a string, was ' + typeof location + '.')
      }
      let locationIndex = hooks.indexOf(location)
      if (locationIndex < 0) {
        throw new Error('ReadingData#addHook(): third argument must be the name of an existing hook.')
      }
      if (preposition === 'before') {
        hooks.splice(locationIndex, 0, hook)
      } else { // preposition === 'after'
        hooks.splice(locationIndex + 1, 0, hook)
      }
      return this
    },

    /**
     * Clean up [.data]{@link module:reading-data~data}, wiping parts or all of it.
     *
     * @param  {String} [scope] The scope to be cleaned.
     * @return {Object} Returns [ReadingData]{@link module:reading-data} to allow for method chaining.
     *
     * @example
     * const RD = require('@delucis/reading-data')
     *
     * // ... Following several .run() cycles, RD.data is cluttered
     * // ... and you want to start from a clean slate.
     *
     * console.log(RD.data) // => { plugin1Data: { ... }, preloadData: [ ... ] }
     *
     * RD.clean()
     *
     * console.log(RD.data) // => {}
     *
     * @example
     * const RD = require('@delucis/reading-data')
     *
     * // ...
     *
     * console.log(RD.data) // => { plugin1Data: { ... }, preloadData: [ ... ] }
     *
     * RD.clean('plugin1Data')
     *
     * console.log(RD.data) // => { preloadData: [ ... ] }
     *
     * @since 0.0.1
     */
    clean: function (scope) {
      if (!scope) {
        this.data = {}
        return this
      }
      if (typeof scope !== 'string') {
        throw new Error('ReadingData#clean(): expected first argument to be a string, but was ' + typeof scope + '.')
      }
      if (this.data.hasOwnProperty(scope)) {
        delete this.data[scope]
        return this
      }
      log.debug('ReadingData#clean(): passed scope not found in data; nothing cleaned.')
      return this
    },

    /**
     * Get an array of currently registered hooks.
     * @return {String[]} An array of registered hook names.
     *
     * @since 0.2.0
     */
    hooks: function () {
      return hooks
    },

    /**
     * Tell [ReadingData]{@link module:reading-data} to use a plugin.
     *
     * Adds the provided `plugin` to [.plugins]{@link module:reading-data~plugins}
     * and merges any provided `opts` with `plugin.config` before adding it to
     * [.config.plugins]{@link module:reading-data~config}.
     *
     * @param  {Object} plugin The plugin to be installed.
     * @param  {Object} [opts] Configuration parameters for the plugin.
     * @return {Object} Returns [ReadingData]{@link module:reading-data} to allow for method chaining.
     *
     * @example
     * const RD = require('@delucis/reading-data')
     * const plugin = require('helpful-reading-data-plugin')
     * const myPluginOptions = {
     *   numberOption: 5,
     *   stringOption: 'Five!'
     * }
     *
     * // Tell ReadingData instance to use the plugin.
     * RD.use(plugin, myPluginOptions)
     *
     * // Trigger ReadingData’s run() cycle, which will call all installed plugins.
     * RD.run()
     *
     * @since 0.0.1
     */
    use: function (plugin, opts) {
      if (typeof plugin !== 'object') {
        throw new Error('ReadingData#use(): first argument must be of type object, but was ' + typeof plugin + '.')
      }
      if (plugins.indexOf(plugin) > -1) {
        log.debug('ReadingData#use(): plugin already installed')
        return this
      }
      let id = generatePluginID()
      let defaultConfig = CLONE((plugin.config || {}))
      let pluginConfig = Object.assign(defaultConfig, (opts || {}))
      config.plugins[id] = pluginConfig
      plugin.__id__ = id
      plugins.push(plugin)
      return this
    },

    /**
     * Remove plugins from the [ReadingData]{@link module:reading-data} instance
     * that were installed with [.use()]{@link module:reading-data~use}.
     *
     * It will unlist plugins from [.plugins]{@link module:reading-data~plugins}
     * and remove their settings from [.config.plugins]{@link module:reading-data~config}.
     *
     * @param  {Object} [plugin] A previously installed plugin object.
     * @return {Object} Returns [ReadingData]{@link module:reading-data} to allow for method chaining.
     *
     * @example
     * const RD = require('@delucis/reading-data')
     * const plugin1 = require('unhelpful-reading-data-plugin')
     * const plugin2 = require('really-unhelpful-reading-data-plugin')
     *
     * // Tell ReadingData to use some plugins.
     * RD.use(plugin1)
     * RD.use(plugin2)
     *
     * // Remove all plugins currently added to the ReadingData instance.
     * RD.uninstall()
     *
     * @example
     * const RD = require('@delucis/reading-data')
     * const plugin1 = require('unhelpful-reading-data-plugin')
     * const plugin2 = require('helpful-reading-data-plugin')
     *
     * // Tell ReadingData to use some plugins.
     * RD.use(plugin1)
     * RD.use(plugin2)
     *
     * // Remove a specific plugin.
     * RD.uninstall(plugin1)
     *
     * @since 0.0.1
     */
    uninstall: function (plugin) {
      if (!plugin) {
        plugins.length = 0
        config.plugins = {}
        return this
      } else if (typeof plugin === 'object' && plugins.indexOf(plugin) > -1) {
        let pluginIndex = plugins.indexOf(plugin)
        let pluginID = plugins[pluginIndex].__id__
        delete config.plugins[pluginID]
        plugins.splice(pluginIndex, 1)
        return this
      } else {
        throw new Error('ReadingData#uninstall(): the first argument must be a plugin object that has already been installed')
      }
    },

    /**
     * Configure ReadingData’s preload options.
     *
     * Utility for configuring the `.preload` and `.preloadData` properties of [.config]{@link module:reading-data~config}.
     *
     * @param  {Object|Boolean} arg Passing an object will register this object as the data to be preloaded and enable preloading. Passing true/false will enable/disable preloading.
     * @return {Object} Returns [ReadingData]{@link module:reading-data} to allow for method chaining.
     *
     * @example
     * const RD = require('@delucis/reading-data')
     * const EXISTING_DATA = require('./some-data-i-saved-earlier.json')
     *
     * RD.preloadData(EXISTING_DATA)
     * // Equivalent to:
     * // RD.config.preload = true
     * // RD.config.preloadData = EXISTING_DATA
     *
     * RD.preloadData(false)
     * // Equivalent to:
     * // RD.config.preload = false
     *
     * @since 0.0.1
     */
    preloadData: function (arg) {
      if (typeof arg === 'object') {
        config.preloadData = arg
        config.preload = true
        return this
      }
      if (typeof arg === 'boolean') {
        config.preload = arg
        return this
      }
      throw new Error('ReadingData#preloadData(): first argument must be an object or a boolean value, but was ' + typeof arg + '.')
    },

    /**
     * Trigger [ReadingData]{@link module:reading-data}’s data gathering cycle:
     * `preload`, `fetch`, `process`.
     *
     * @return {Object} Returns [ReadingData]{@link module:reading-data} to allow for method chaining.
     *
     * @example <caption>Use with Promise-style syntax</caption>
     * const RD = require('@delucis/reading-data')
     * const helpfulPlugin = require('helpful-reading-data-plugin')
     *
     * RD.use(helpfulPlugin)
     *
     * RD.run().then((result) => {
     *   console.log(result.data)
     * })
     *
     * @example <caption>Use in an asynchronous function</caption>
     * const RD = require('@delucis/reading-data')
     * const helpfulPlugin = require('helpful-reading-data-plugin')
     *
     * RD.use(helpfulPlugin)
     *
     * let asynchronousDataLogger = async function () {
     *   await RD.run()
     *   console.log(RD.data)
     * }
     *
     * asynchronousDataLogger()
     *
     * @since 0.0.1
     */
    run: async function () {
      let timer = TIMESPAN()
      meta.runtime = meta.runtimePretty = null
      preload(this)
      await callHooks(this)
      meta.runtime = timer()
      meta.runtimePretty = MS(meta.runtime)
      return this
    }
  }
}())

module.exports = ReadingData