Source: core/src/util/translator.js

/**
 * Mapping unit.
 *
 * @typedef {Object} module:@citation-js/core.util.Translator~statement
 * @property {String|Array<String>} [source] - properties to source value from
 * @property {String|Array<String>} [target] - properties the value should go to
 * @property {Object} [convert] - convert serialized or nested values
 * @property {module:@citation-js/core.util.Translator~convertProp} [convert.toTarget] - function to convert source prop to target
 * @property {module:@citation-js/core.util.Translator~convertProp} [convert.toSource] - function to convert target prop to source
 * @property {Object} [when] - conditions as to when this statement should apply
 * @property {module:@citation-js/core.util.Translator~condition} [when.source]
 * @property {module:@citation-js/core.util.Translator~condition} [when.target]
 */

/**
 * In the case of toTarget, source is input and target is output. In the case of
 * toSource, source is output and target is input.
 *
 * @callback module:@citation-js/core.util.Translator~convertProp
 * @param {...*} input - input values
 * @return {Array|*} If output is an array and multiple output properties are
 *                   specified, the output is divided over those properties.
 */

/**
 * A top-level Boolean enables or disables a mapping unit in a given direction.
 * Otherwise, individual properties are checked with an object specifying the
 * property name and one of four things:
 *
 *  - A boolean, checking for presence of the property
 *  - An array of values for checking whether the property value is in them
 *  - A value that should match with the property value
 *  - A predicate function taking in the value and returning a boolean
 *
 * All conditions have to be fulfilled for the mapping unit to be enabled.
 *
 * @typedef {Boolean|Object<String,Boolean|Array<*>|module:@citation-js/core.util.Translator~conditionPropPredicate|*>} module:@citation-js/core.util.Translator~condition
 */

/**
 * Return, based on a property, whether a mapping should apply.
 *
 * @callback module:@citation-js/core.util.Translator~conditionPropPredicate
 * @param {*} input - input value
 * @return {Boolean}
 */

/**
 * Return, whether a mapping should apply.
 *
 * @callback module:@citation-js/core.util.Translator~conditionPredicate
 * @param {Object} input - input
 * @return {Boolean}
 */

/**
 * @access private
 * @memberof module:@citation-js/core.util.Translator
 * @param {module:@citation-js/core.util.Translator~condition} condition
 * @return {module:@citation-js/core.util.Translator~conditionPredicate}
 */
function createConditionEval (condition) {
  return function conditionEval (input) {
    if (typeof condition === 'boolean') {
      return condition
    }

    return Object.keys(condition).every(prop => {
      const value = condition[prop]
      if (value === true) {
        return prop in input
      } else if (value === false) {
        return !(prop in input)
      } else if (typeof value === 'function') {
        return value(input[prop])
      } else if (Array.isArray(value)) {
        return value.includes(input[prop])
      } else {
        return input[prop] === value
      }
    })
  }
}

/**
 * @access private
 * @typedef {Object} module:@citation-js/core.util.Translator~normalizedStatement
 * @property {Array<String>} inputProp
 * @property {Array<String>} outputProp
 * @property {module:@citation-js/core.util.Translator~convertProp} convert
 * @property {module:@citation-js/core.util.Translator~conditionPredicate} condition
 */

/**
* @access private
* @memberof module:@citation-js/core.util.Translator
* @param {module:@citation-js/core.util.Translator~statement} prop
* @param {Boolean} toSource
* @return {module:@citation-js/core.util.Translator~normalizedStatement} normalized one-directional object
*/
function parsePropStatement (prop, toSource) {
  let inputProp
  let outputProp
  let convert
  let condition

  if (typeof prop === 'string') {
    inputProp = outputProp = prop
  } else if (prop) {
    inputProp = toSource ? prop.target : prop.source
    outputProp = toSource ? prop.source : prop.target

    if (prop.convert) {
      convert = toSource ? prop.convert.toSource : prop.convert.toTarget
    }

    if (prop.when) {
      condition = toSource ? prop.when.target : prop.when.source
      if (condition != null) {
        condition = createConditionEval(condition)
      }
    }
  } else {
    return null
  }

  inputProp = [].concat(inputProp).filter(Boolean)
  outputProp = [].concat(outputProp).filter(Boolean)

  return { inputProp, outputProp, convert, condition }
}

/**
 * Return, whether a mapping should apply.
 *
 * @callback module:@citation-js/core.util.Translator~convert
 * @param {Object} input - input
 * @return {Object} output
 */

/**
* @access private
* @memberof module:@citation-js/core.util.Translator
* @param {Array<module:@citation-js/core.util.Translator~statement>} props
* @param {Boolean} toSource
* @return {module:@citation-js/core.util.Translator~convert} converter
*/
function createConverter (props, toSource) {
  toSource = toSource === Translator.CONVERT_TO_SOURCE
  props = props.map(prop => parsePropStatement(prop, toSource)).filter(Boolean)

  return function converter (input) {
    const output = {}

    for (const { inputProp, outputProp, convert, condition } of props) {
      // Skip when no output will be assigned
      if (outputProp.length === 0) {
        continue
      // Skip when requested by the requirements of the prop converter
      } else if (condition && !condition(input)) {
        continue
      // Skip when none of the required props are in the input data
      // NOTE: if no input is required, do not skip
      } else if (inputProp.length !== 0 && inputProp.every(prop => !(prop in input))) {
        continue
      }

      let outputData = inputProp.map(prop => input[prop])
      if (convert) {
        try {
          const converted = convert.apply(input, outputData)
          outputData = outputProp.length === 1 ? [converted] : converted
        } catch (cause) {
          throw new Error(`Failed to convert ${inputProp} to ${outputProp}`, { cause })
        }
      }

      outputProp.forEach((prop, index) => {
        const value = outputData[index]
        if (value !== undefined) {
          output[prop] = value
        }
      })
    }

    return output
  }
}

/**
 * @memberof module:@citation-js/core.util
 *
 * @param {Array<module:@citation-js/core.util.Translator~statement>} props
 *
 * @todo proper merging (?)
 * @todo 'else' conditions
 */
class Translator {
  constructor (props) {
    /**
     * @type {module:@citation-js/core.util.Translator~convert}
     */
    this.convertToSource = createConverter(props, Translator.CONVERT_TO_SOURCE)

    /**
     * @type {module:@citation-js/core.util.Translator~convert}
     */
    this.convertToTarget = createConverter(props, Translator.CONVERT_TO_TARGET)
  }
}

/**
 * @memberof module:@citation-js/core.util.Translator
 * @property {Symbol} CONVERT_TO_SOURCE
 */
Translator.CONVERT_TO_SOURCE = Symbol('convert to source')

/**
 * @memberof module:@citation-js/core.util.Translator
 * @property {Symbol} CONVERT_TO_TARGET
 */
Translator.CONVERT_TO_TARGET = Symbol('convert to target')

export { Translator }