Source: obj/copyprops.js


// Get some constants
const {S,B,N,F,isObj,isArray,needObj,def,console} = require('../types');
const {copyAll, duplicateOne: clone} = require('./copyall');

const RECURSE_NONE =  0;
const RECURSE_ALL  = -1;
const RECURSE_LIST = -2;

/**
 * Copy properties from one object to another.
 *
 * @param {(object|function)} source - The object to copy properties from.
 * @param {(object|function)} target - The target to copy properties to.
 *
 * @param {object} [opts] Options for how to copy properties.
 * @param {boolean} [opts.default=true] Copy only enumerable properties.
 * @param {boolean} [opts.all=false] Copy ALL object properties.
 * @param {Array} [opts.props] A list of specific properties to copy.
 * @param {Array} [opts.exclude] A list of properties NOT to copy.
 * @param {object} [opts.overrides] Descriptor overrides for properties.
 * 
 * The object is considered a map, where each *key* is the name of the
 * property, and the value should be an `object` containing any valid
 * descriptor properties.
 * 
 * If `opts.default` is explicitly set to `false` and `opts.overrides` 
 * is set, then not only will it be used as a list of overrides, 
 * but only the properties specified in it will be copied.
 * 
 * @param {*} [opts.overwrite=false] Overwrite existing properties.
 * 
 * If this is a `boolean` value, it will allow or disallow overwriting
 * of any and all properties in the target object.
 *
 * If this is an `object`, it can be an `Array` of property names to allow
 * to be overwritten, or a *map* of property name to a `boolean` indicating
 * if that property can be overwritten or not.
 * 
 * Finally, if this is a `function` it'll be passed the property name and
 * must return a boolean indicating if overwriting is allowed.
 * 
 * @param {number} [opts.recursive=0] Enable recursive copying of objects.
 * 
 * If it is `0` (also `copyProps.RECURSE_NONE`) then no recursion is done.
 * In this case, the regular assignment rules (including `opts.overwrite`)
 * will be used regardless of the property type. 
 * This is the default value.
 * 
 * If this is *above zero*, it's the recursion depth for `object` properties.
 * 
 * If this is *below zero*, then it should be one of the constant values:
 * 
 * | Contant                  | Value | Description                          |
 * | ------------------------ | ----- | ------------------------------------ |
 * | `copyProps.RECURSE_ALL`  | `-1`  | Recurse to an *unlimited* depth.     |
 * | `copyProps.RECURSE_LIST` | `-2`  | Recurse `opts.recurseOpts` props.    |
 * 
 * @param {object} [opts.recurseOpts] Options for recursive properties.
 * 
 * If `opts.recursive` is not `0` then `opts.recurseOpts` can be a map of
 * property names to further objects, which will be used as the `opts` for
 * that property when calling `copyProps()` recursively.
 * 
 * So you could have nested `opts.recurseOpts` values if required.
 * 
 * The `recursive` property will automatically be added to the individual
 * `recurseOpts`, automatically applying the correct value.
 * 
 * The `opts.recurseOpts` option has an extra-special meaning if `opts.recurse`
 * is set to `RECURSE_LIST`, as then *only* the properties with options defined
 * in `opts.recurseOpts` will be recursed. The rest will simply be copied.
 *
 * @returns {object} The `target` object.
 * @alias module:@lumjs/core/obj.copyProps
 */
function copyProps(source, target, opts={})
{
  //console.debug("copyProps", source, target, propOpts);
  needObj(source, true, 'source must be an object or function');
  needObj(target, true, 'target must be an object or function');
  needObj(opts, false, 'opts must be an object');

  const useDefaults = opts.default   ?? true;
  const overrides   = opts.overrides ?? {};
  
  let recursive;
  if (typeof opts.recursive === N)
  {
    recursive = opts.recursive;
  }
  else if (typeof opts.recursive === B)
  {
    recursive = opts.recursive ? RECURSE_ALL : RECURSE_NONE;
  }
  else 
  {
    recursive = RECURSE_NONE;
  }

  let recurseCache, recurseOpts;
  if (recursive !== RECURSE_NONE)
  {
    recurseCache = opts.recurseCache ?? [];
    recurseOpts  = opts.recurseOpts  ?? {};
  }

  let overwrites;
  if (typeof opts.overwrite === F)
  { // A custom function.
    overwrites = opts.overwrite;
  }
  else if (isObj(opts.overwrite))
  { // An object may be an array or a map.
    if (isArray(opts.overwrite))
    { // A flat array of properties to overwrite.
      overwrites = prop => opts.overwrite.includes(prop);
    }
    else 
    { // A map of properties to overwrite.
      overwrites = prop => opts.overwrite[prop] ?? false;
    }
  }
  else 
  { // The only other values should be boolean.
    overwrites = () => opts.overwrite ?? false;
  }

  const exclude = isArray(opts.exclude) ? opts.exclude : null;

  let propDefs;

  if (isArray(opts.props))
  {
    propDefs = opts.props;
  }
  else if (opts.all)
  {
    propDefs = Object.getOwnPropertyNames(source); 
  }
  else if (useDefaults)
  {
    propDefs = Object.keys(source);
  }
  else
  {
    propDefs = Object.keys(overrides);
  }

  if (!propDefs)
  {
    console.error("Could not determine properties to copy", opts);
    return;
  }

  // For each propDef found, add it to the target.
  for (const prop of propDefs)
  {
    //console.debug(" @prop:", prop);
    if (exclude && exclude.indexOf(prop) !== -1)
    { // Excluded property.
      continue;
    }

    let desc = Object.getOwnPropertyDescriptor(source, prop);
    //console.debug(" @desc:", def);
    if (desc === undefined) 
    { // A non-existent property.
      continue; // Invalid property.
    }

    if (isObj(overrides[prop]))
    { // Overriding descriptor properties.
      desc = clone(desc);
      for (const key in overrides[prop])
      {
        const val = overrides[prop][key];
        desc[key] = val;
      }
    }

    let overwrite = overwrites(prop);

    if (recursive !== 0 
      && (recursive !== RECURSE_LIST || isObj(recurseOpts[prop]))
      && isObj(desc.value) 
      && isObj(target[prop])
      && !recurseCache.includes(desc.value) 
      && !recurseCache.includes(target[prop]))
    { // Recursive mode is enabled, so we're going to go deeper.
      recurseCache.push(decs.value);
      if (desc.value !== target[prop])
      { // They're not the same literal object already.
        recurseCache.push(target[prop]);
        const ropts 
          = isObj(recurseOpts[prop]) 
          ? clone(recurseOpts[prop]) 
          : {overwrite};
        // Always set the cache.
        ropts.recurseCache = recurseCache;
        if (typeof ropts.recursive !== N)
        { // Set the recursive option.
          ropts.recursive = (recursive > 0) ? recursive - 1 : recursive;
        }
        // Okay, we're ready, let's recurse now!
        copyProps(desc.value, target[prop], ropts);
      }
    }
    else if (overwrite || target[prop] === undefined)
    { // Property doesn't already exist, let's add it.
      def(target, prop, desc);
    }
  }

  //console.debug("copyProps:done", target);

  return target;
} // copyProps()

def(copyProps, 'RECURSE_NONE', RECURSE_NONE);
def(copyProps, 'RECURSE_ALL',  RECURSE_ALL);
def(copyProps, 'RECURSE_LIST', RECURSE_LIST);

/**
 * A class providing a declarative `copyProps()` API,
 * which makes it easy to copy one or more sources,
 * into one or more targets.
 * 
 * This class is not directly accessible, and instead is available
 * via some special sub-methods of `copyProps`; examples:
 * 
 * ```js
 * // Get a copy of the `copyProps` function.
 * const cp = require('@lumjs/core').obj.copyProps;
 * 
 * // Starting with the target(s):
 * cp.into(targetObj).given({all: true}).from(source1, source2);
 * 
 * // Starting with the source(s):
 * cp.from(sourceObj).given({exclude: ['dontCopy']}).into(target1, target2);
 * 
 * // Starting with the options:
 * cp.given({recursive: cp.RECURSE_ALL}).from(source1, source2).into(target1, target2);
 * 
 * // Call `cp.into()` and cache the instance in a `Map`.
 * // Future calls with the same `targetObj` will return the cached instance.
 * // Unlike `cp.into()`, only supports a single `targetObj`.
 * cp.cache.into(targetObj);
 *
 * // Call `cp.from()` and cache the instance in a `Map`.
 * // Future calls with the same `sourceObj` will return the cached instance.
 * // Unlike `cp.from()`, only supports a single `sourceObj`.
 * cp.cache.from(sourceObj);
 * 
 * // Clear the `Map` instances for `cp.cache.into` and `cp.cache.from`
 * cp.cache.clear();
 *
 * ```
 * 
 * @alias module:@lumjs/core/obj~CopyProps
 */
class $CopyProps
{
  // Constructor is private so we won't document it.
  constructor(opts={})
  {
    this.opts = opts;
    this.sources = [];
    this.targets = [];
  }

  /**
   * Set options.
   * 
   * @param {(string|object)} opt - Option(s) to set.
   * 
   * If this is a `string` it's the name of an option to set.
   * If this is an `object`, it's map of options to set with `copyAll`.
   * 
   * @param {*} value - The option value.
   * 
   * Only used if `option` is a `string`.
   * 
   * @returns {object} `this`
   */
  set(opt, value)
  {
    //console.debug("CopyProps.set(", opt, value, ')');
    if (typeof opt === S)
    { // Set a single option.
      this.opts[opt] = value;
    }
    else if (isObj(opt))
    { // Set a bunch of options.
      copyAll(this.opts, opt);
    }
    else
    { // That's not supported
      console.error("invalid opt", {opt, value, cp: this});
    }
    //console.debug("CopyProps.set:after", this);
    return this;
  }

  /**
   * Set all options.
   * 
   * This replaces any existing options entirely.
   * 
   * @param {object} opts - The options to set.
   * @returns {object} `this`
   */
  given(opts)
  {
    needObj(opts);
    this.opts = opts;
    return this;
  }

  /**
   * Specify the `targets` to copy properties into.
   * 
   * @param {...object} [targets] The target objects
   * 
   * If `this.sources` has objects in it already,
   * then we'll run `copyProps()` for each of the 
   * sources into each of the `targets`.
   * 
   * If `this.sources` is empty, then this will set
   * `this.target` to the specified value.
   * 
   * You can specify no sources at all to clear the 
   * currently set `this.targets` value.
   * 
   * @returns {object} `this`
   */
  into(...targets)
  {
    if (this.sources.length > 0 && targets.length > 0)
    {
      this.$run(this.sources, targets);
    }
    else 
    {
      this.targets = targets;
    }
    return this;
  }

  /**
   * Specify the `sources` to copy properties from.
   * 
   * @param  {...object} [sources] The source objects.
   * 
   * If `this.targets` has objects in it already, 
   * then we'll run `copyProps()` for each of the 
   * `sources` into each of the targets.
   * 
   * If `this.targets` is empty, then this will set
   * `this.sources` to the specified value.
   * 
   * You can specify no sources at all to clear the 
   * currently set `this.sources` value.
   * 
   * @returns {object} `this`
   */
  from(...sources)
  {
    if (this.targets.length > 0 && sources.length > 0)
    {
      this.$run(sources, this.targets);
    }
    else 
    {
      this.sources = sources;
    }
    return this;
  }

  // Protected method doesn't need to be documented.
  $run(sources, targets)
  {
    for (const source of sources)
    {
      for (const target of targets)
      {
        copyProps(source, target, this.opts);
      }
    }
  }

}

copyProps.given = function(opts)
{
  return new $CopyProps(opts);
}

copyProps.into = function(...targets)
{
  return ((new $CopyProps()).into(...targets));
}

copyProps.from = function(...sources)
{
  return ((new $CopyProps()).from(...sources));
}

const CPC = copyProps.cache =
{
  into(target)
  {
    if (this.intoCache === undefined)
      this.intoCache = new Map();
    const cache = this.intoCache;
    if (cache.has(target))
    {
      return cache.get(target);
    }
    else
    {
      const cp = copyProps.into(target);
      cache.set(target, cp);
      return cp;
    }
  },
  from(source)
  {
    if (this.fromCache === undefined)
      this.fromCache = new Map();
    const cache = this.fromCache;
    if (cache.has(source))
    {
      return cache.get(source);
    }
    else
    {
      const cp = copyProps.from(source);
      cache.set(source, cp);
      return cp;
    }
  },
  clear()
  {
    if (this.intoCache)
      this.intoCache.clear();
    if (this.fromCache)
      this.fromCache.clear();
    return this;
  },
};

module.exports = copyProps;