Utils.js

/**
 *  File    : Utils.js
 *  Created : 01/04/2015
 *  By      : Francesc Busquets <francesc@gmail.com>
 *
 *  JClic.js
 *  An HTML5 player of JClic activities
 *  https://projectestac.github.io/jclic.js
 *
 *  @source https://github.com/projectestac/jclic.js
 *
 *  @license EUPL-1.2
 *  @licstart
 *  (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
 *
 *  Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
 *  the European Commission- subsequent versions of the EUPL (the "Licence");
 *  You may not use this work except in compliance with the Licence.
 *
 *  You may obtain a copy of the Licence at:
 *  https://joinup.ec.europa.eu/software/page/eupl
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
 *  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 *  Licence for the specific language governing permissions and limitations
 *  under the Licence.
 *  @licend
 *  @module
 */

/* global Promise, window, document, console, HTMLElement */

import $ from 'jquery';
import * as clipboard from 'clipboard-polyfill';
import JSZip from 'jszip';
import JSZipUtils from 'jszip-utils';
import WebFont from 'webfontloader';
import GlobalData from './GlobalData';

/**
 * Exports third-party NPM packages used by JClic, so they become available to other scripts through
 * the global variable `JClicObject` (defined in {@link module:JClic.JClic})
 * @example <caption>Example usage of JSZip through JClicObject</caption>
 * var WebFont = window.JClicObject.Utils.pkg.WebFont;
 * WebFont.load({google: {families: ['Roboto']}});
 * @type: {object}
 */
export const pkg = {
  clipboard,
  $,
  JSZip,
  JSZipUtils,
  WebFont,
};

/**
 * List of valid verbosity levels
 * @const {string[]}
 */
export const LOG_LEVELS = ['none', 'error', 'warn', 'info', 'debug', 'trace', 'all'];

/**
 * Labels printed on logs for each message type
 * @const {string[]}
 */
export const LOG_PRINT_LABELS = ['     ', 'ERROR', 'WARN ', 'INFO ', 'DEBUG', 'TRACE', 'ALL  '];

/**
 * Options of the logging system
 * @type {object} */
export const LOG_OPTIONS = {
  level: 2, // warn
  prefix: 'JClic',
  timestamp: true,
  popupOnErrors: false,
  chainTo: null,
  pipeTo: null,
};

/**
 * Current dictionary of string translations
 */
let _messages = {};

/**
 * Initializes the global settings
 * @param {object} options - An object with global settings
 * @param {boolean} [setLog=true] - When `true`, the log level will be set
 * @param {boolean} [setLang=true] - When `true`, the current language will be set
 * @returns {object} The normalized `options` object
 */
export function init(options, setLog = true, setLang = true) {
  options = normalizeObject(options);
  if (setLog) {
    if (typeof options.logLevel !== 'undefined')
      setLogLevel(options.logLevel);
    if (typeof options.chainLogTo === 'function')
      LOG_OPTIONS.chainTo = options.chainLogTo;
    if (typeof options.pipeLogTo === 'function')
      LOG_OPTIONS.pipeTo = options.pipeLogTo;
  }

  if (setLang) {
    const lngRequested = options.lang;
    const lng = checkPreferredLanguage(GlobalData.languages, 'en', lngRequested);
    log('debug', `Language ${lngRequested ? `requested: "${lngRequested}" ` : ''} used: "${lng}"`);
    _messages = lng === 'en' ? {} : GlobalData.messages[lng];
  }

  return options;
};

/**
 * Function that will return the translation of the provided key
 * into the current language.
 * @param {string} key - ID of the expression to be translated
 * @returns {string} - The translated text
 */
export function getMsg(key) {
  return _messages[key] || key;
}

/**
 * Converts expressions of type 'pt-br', 'FR', 'ca_es@valencia'... to the format expected by the i18n system:
 * lc[_CC][@variant] where 'lc' is a two or three lowercase letter language code, CC is an optional two uppercase
 * letter country code, followed by an optional 'variant' consisting in letters and/or digits.
 * @param {string} locale - The locale expression to be normalized
 * @returns string - The normalized locale
 */
export function normalizeLocale(locale = '') {
  const [, language = null, country = null, variant = null] = /^([a-zA-Z]{2,3})[_-]?([a-zA-Z]{2})?@?([a-zA-Z0-9]*)?$/.exec(locale.trim()) || [];
  return language
    ? `${language.toLowerCase()}${country ? `_${country.toUpperCase()}` : ''}${variant ? `@${variant.toLowerCase()}` : ''}`
    : '';
};

/**
 * Checks if the language preferred by the user (based on browser and/or specific settings)
 * is in a list of available languages.
 * @param {string[]} availableLangs - Array of available languages. It should contain at least one item.
 * @param {string} [defaultLang=en] - Language to be used by default when not found the selected one
 * @param {string} [requestedLang=''] - Request this specific language
 * @returns {string} - The most suitable language for this request
 */
export function checkPreferredLanguage(availableLangs, defaultLang = 'en', requestedLang = '') {
  let result = -1;

  // Create an array to store possible values
  let tries = [];

  // If "setLang" is specified, check it
  if (requestedLang) {
    // Normalize requested locale
    const lang = normalizeLocale(requestedLang);
    if (lang)
      tries.push(lang);
  }

  // Add user's preferred languages, if any
  if (window.navigator.languages)
    tries = tries.concat(window.navigator.languages);

  // Add the navigator main language, if defined
  if (window.navigator.language)
    tries.push(window.navigator.language);

  // Add English as final option
  tries.push(defaultLang);

  for (let i = 0; i < tries.length; i++) {
    let match = -1;
    for (let n in availableLangs) {
      if (tries[i].indexOf(availableLangs[n]) === 0) {
        match = n;
        if (tries[i] === availableLangs[n]) {
          result = n;
          break;
        }
      }
    }
    if (result >= 0 || (result = match) >= 0)
      break;
  }
  return availableLangs[result >= 0 ? result : 0];
};

/**
 * Establishes the current verbosity level of the logging system
 * @param {string} level - One of the valid strings in {@link module:Utils.LOG_LEVELS}
 */
export function setLogLevel(level) {
  const log = LOG_LEVELS.indexOf(level);
  if (log >= 0)
    LOG_OPTIONS.level = log;
};

/**
 * Reports a new message to the logging system
 * @param {string} type - The type of message. Mus be `error`, `warn`, `info`, `debug` or `trace`.
 * @param {string} msg - The main message to be logged. Additional parameters can be added, like
 * in `console.log` (see: {@link https://developer.mozilla.org/en-US/docs/Web/API/Console/log})
 */
export function log(type, msg) {
  const level = LOG_LEVELS.indexOf(type);
  const args = Array.prototype.slice.call(arguments);

  // Check if message should currently be logged
  if (level < 0 || level <= LOG_OPTIONS.level) {
    if (LOG_OPTIONS.pipeTo)
      LOG_OPTIONS.pipeTo.apply(null, args);
    else {
      const mainMsg = `${LOG_OPTIONS.prefix || ''} ${LOG_PRINT_LABELS[level]} ${LOG_OPTIONS.timestamp ? getDateTime() : ''} ${msg}`;
      console[level === 1 ? 'error' : level === 2 ? 'warn' : 'log'].apply(console, [mainMsg].concat(args.slice(2)));
      // Call chained logger, if anny
      if (LOG_OPTIONS.chainTo)
        LOG_OPTIONS.chainTo.apply(null, args);
    }
  }
};

/**
 * Gets a boolean value from a textual expression
 * @param {string} val - The value to be parsed (`true` for true, null or otherwise for `false`)
 * @param {boolean} [defaultValue=false] - The default value to return when `val` is false
 * @returns {number}
 */
export function getBoolean(val, defaultValue = false) {
  return val === 'true' ? true : val === 'false' ? false : defaultValue;
};

/**
 * Gets a value from an given expression that can be `null`, `undefined` or empty string ('')
 * @param {any} val - The expression to parse
 * @param {any} [defaultValue=null] - The value to return when `val` is `null`, `''` or `undefined`
 * @returns {any}
 */
export function getVal(val, defaultValue = null) {
  return (val === '' || val === null || typeof val === 'undefined') ? defaultValue : val;
};

/**
 * Gets a number from a string or another number
 * @param {any} val - The expression to parse
 * @param {number} [defaultValue=0] - The default value
 * @returns {number}
 */
export function getNumber(val, defaultValue) {
  return Number(getVal(val, defaultValue));
};

/**
 * Gets the plain percent expression (without decimals) of the given value
 * @param {number} val - The value to be expressed as a percentile
 * @returns {string}
 */
export function getPercent(val) {
  return `${Math.round(val * 100)}%`;
}

/**
 * Returns the two-digits text expression representing the given number (lesser than 100) zero-padded at left
 * Useful for representing hours, minutes and seconds
 * @param {number} val - The number to be processed
 * @returns {string}
 */
export function zp(val) {
  return `0${val}`.slice(-2);
};

/**
 * Returns a given time in [00h 00'00"] format
 * @param {number} millis - Amount of milliseconds to be processed
 * @returns {string}
 */
export function getHMStime(millis) {
  const d = new Date(millis);
  const h = d.getUTCHours(), m = d.getUTCMinutes(), s = d.getUTCSeconds();
  return `${h ? h + 'h ' : ''}${h || m ? zp(m) + '\'' : ''}${zp(s)}"`;
};

/**
 * Returns a formatted string with the provided date and time
 * @param {external:Date} date - The date to be formatted. When `null` or `undefined`, the current date will be used.
 * @returns {string}
 */
export function getDateTime(date = new Date()) {
  return `${date.getFullYear()}/${zp(date.getMonth() + 1)}/${zp(date.getDate())} ${zp(date.getHours())}:${zp(date.getMinutes())}:${zp(date.getSeconds())}`;
};

/**
 * Parse 'date' fields generated by "JClic Author" in format d/m/y, with
 * variable number of digits.
 * @param {string} text - The old 'date' field
 * @returns {external:Date} - Always return a Date object (now, if text was invalid)
 */
export function parseOldDate(text) {
  let result = null;
  if (text) {
    const elements = text.trim().split('/');
    if (elements.length === 3) {
      let m = parseInt(elements[0]) || 0;
      let d = parseInt(elements[1]) || 0;
      let y = parseInt(elements[2]) || 0;
      if (m > 12 && d <= 12) {
        const t = m;
        m = d;
        d = t;
      }
      if (y < 1980)
        y += (y < 90 ? 2000 : 1900);
      if (d && m && y) {
        result = new Date(Date.parse(`${m}/${d}/${y}`));
      }
    }
  }
  return result || new Date();
};

/**
 * Extracts just the ISO-639 language code from complex
 * expressions like "English (en)", buid by JClic Author.
 * @param {string} text - The expression to parse
 * @returns {string} - The ISO-639 language code, or '--' if none found
 */
export function cleanOldLanguageTag(text) {
  if (!text)
    text = '--';
  // Allow only ISO-639-1 and ISO-639-2 language codes
  else if (!text.match(/^[a-z][a-z][a-z]?$/)) {
    const matches = text.match(/\(([a-z][a-z][a-z]?)\)/);
    if (matches && matches.length === 2)
      text = matches[1];
    else
      text = '--';
  }
  return text;
};

/** @const {number} */
export const FALSE = 0;

/** @const {number} */
export const TRUE = 1;

/** @const {number} */
export const DEFAULT = 2;

/**
 * Gets a numeric value (0, 1 or 2) from a set of possible values: `false`, `true` and `default`.
 * @param {string} val - The text to be parsed
 * @param {any} def - An optional default value
 * @returns {number}
 */
export function getTriState(val, def = DEFAULT) {
  return val === 'true' ? TRUE : val === 'false' ? FALSE : def;
};

/**
 * Returns a string with the given `tag` repeated n times
 * @param {string} tag - The tag to be repeated
 * @param {number} repeats - The number of times to repeat the tag
 * @returns {string}
 */
export function fillString(tag, repeats = 0) {
  return Array(repeats).fill(tag).join('');
};

/**
 * Checks if the provided value is 'null' or 'undefined'.
 * @param {any} val - The value to be parsed
 * @returns {boolean}
 */
export function isNullOrUndef(val) {
  return typeof val === 'undefined' || val === null;
};

/**
 * Checks if two expressions are equivalent.
 * Returns `true` when both parameters are `null` or `undefined`, and also when both have
 * equivalent values.
 * @param {any} a
 * @param {any} b
 * @returns {boolean}
 */
export function isEquivalent(a, b) {
  return (typeof a === 'undefined' || a === null) && (typeof b === 'undefined' || b === null) || a === b;
};

/**
 * Reads paragraphs, identified by `<p></p>` elements, inside XML data
 * @param {object} xml - The DOM-XML element to be parsed
 * @returns {string}
 */
export function getXmlText(xml) {
  let text = '';
  $(xml).children('p').each((_n, child) => { text += `<p>${child.textContent}</p>`; });
  return text;
};

/**
 * Parse the provided XML element node, returning a complex object
 * @param {object} xml - The root XML element to parse
 * @param {boolean} [withText=false] - When `true`, any text found inside the XML element is also included in the resulting object.
 * @returns {object}
 */
export function parseXmlNode(xml, withText = false) {
  // Initialize the resulting object
  const result = {};
  // Direct copy of root element attributes as object properties
  if (xml.attributes)
    attrForEach(xml.attributes, (name, value) => result[name] = /^-?\d*$/.test(value) ? Number(value) : value);

  const keys = [];
  const children = Array.from(xml.children || xml.childNodes || []);

  // If all children is of type 'p', just compile it in a single string
  const paragraphs = children.filter(child => child.nodeName === 'p');
  if (paragraphs.length > 0 && paragraphs.length === children.filter(ch => ch.nodeName !== '#text').length) {
    const text = paragraphs.map(ch => ch.textContent).join('\n');
    if (xml.attributes) {
      result.text = text;
      return result;
    }
    return text;
  }

  // Process children elements
  children.forEach(child => {
    // Avoid extra text content collected by [xmldom](https://www.npmjs.com/package/xmldom)
    if (child.nodeName === '#text' && !withText)
      return;

    // Recursive processing of children
    const ch = parseXmlNode(child, withText);
    // Store the result into a temporary object named as the child node name,
    if (!result[child.nodeName]) {
      // Create object and save key for later processing
      result[child.nodeName] = {};
      keys.push(child.nodeName);
    }
    // Use 'id' (or an incremental number if 'id' is not set) as a key
    if (ch.id)
      result[child.nodeName][ch.id] = ch;
    else {
      const n = Object.keys(result[child.nodeName]).length;
      result[child.nodeName][n] = ch;
    }
  });
  // Check temporary objects, converting it to an array, a single object or a complex object
  keys.forEach(k => {
    // Retrieve temporary object from `keys`
    const kx = Object.keys(result[k]);
    // If all keys are numbers, convert object into an array (or leave it as a single object)
    if (!kx.find(kk => isNaN(kk))) {
      if (kx.length === 1)
        // Array with a single element. Leave it as a simple object:
        result[k] = result[k][0];
      else {
        // Object with numeric keys. Convert it to array:
        const arr = [];
        kx.forEach(kk => arr.push(result[k][kk]));
        result[k] = arr;
      }
    }
  });
  // Save text content, if any:
  if (children.length === 0 && xml.textContent)
    result.textContent = xml.textContent;
  return result;
};

/**
 * Parse the given XML node, known as containing only text elements,
 * and return its content as a string (when possible)
 * @param {object} xml - The XML element to parse
 * @returns {string|object}
 */
export function getXmlNodeText(node) {
  const result = parseXmlNode(node);
  return typeof result === 'string' ?
    result :
    result.hasOwnProperty('text') ?
      result.text :
      result.hasOwnProperty('textContent') ?
        result.textContent :
        result;
};

/**
 * Recursively explore the given object, converting to a string
 * all attributes with a single attribute named 'text'.
 * Example:
 * {a:1, b:{text:"hello"}, c:{d:2, text:"world"}} => {a:1, b:"hello", c:{d:2, text:"world"}}
 * @param {object} obj - The object to explore
 * @returns {object} - The same object, with text attributes reduced to strings
 */
export function reduceTextsToStrings(obj) {
  if (obj) {
    const keys = Object.keys(obj);
    keys.forEach(k => {
      const attr = obj[k];
      if (typeof attr === 'object') {
        const ko = Object.keys(attr);
        if (ko.length === 1 && ko[0] === 'text')
          obj[k] = attr.text;
        else
          obj[k] = reduceTextsToStrings(attr);
      }
    });
  }
  return obj;
};

/**
 * Creates a string suitable to be used in the 'style' attribute of HTML tags, filled with the
 * CSS attributes contained in the provided object.
 * @param {object} cssObj
 * @returns {string}
 */
export function cssToString(cssObj) {
  return Object.keys(cssObj).reduce((s, key) => `${s}${key}:${cssObj[key]};`, '');
};

/**
 * Converts java-like color codes (like '0xRRGGBB') to valid CSS values like '#RRGGBB' or 'rgba(r,g,b,a)'
 * @param {string} [color] - A color, as codified in Java
 * @param {string} [defaultColor] - The default color to be used
 * @returns {string}
 */
export function checkColor(color, defaultColor = settings.BoxBase.BACK_COLOR) {
  if (typeof color === 'undefined' || color === null)
    color = defaultColor;
  color = color.replace('0x', '#');
  // Check for Alpha value
  if (color.charAt(0) === '#' && color.length > 7) {
    const alpha = fx(parseInt(color.substring(1, 3), 16) / 255.0, 2);
    color = `rgba(${parseInt(color.substring(3, 5), 16)},${parseInt(color.substring(5, 7), 16)},${parseInt(color.substring(7, 9), 16)},${alpha})`;
  }
  return color;
};

/**
 * Checks if the provided color has an alpha value less than 1
 * @param {string} color - The color to be analyzed
 * @returns {boolean}
 */
export function colorHasTransparency(color) {
  if (startsWith(color, 'rgba(')) {
    var alpha = parseInt(color.substr(color.lastIndexOf(',')));
    return typeof alpha === 'number' && alpha < 1.0;
  }
  return false;
};

/**
 * Clones the provided object
 * See: https://stackoverflow.com/questions/41474986/how-to-clone-a-javascript-es6-class-instance
 * @param {object} obj
 * @returns {object}
 */
//cloneObject: obj => Object.assign(Object.create(Object.getPrototypeOf(obj)), obj),
export function cloneObject(obj) {
  return $.extend(true, Object.create(Object.getPrototypeOf(obj)), obj);
};

/**
 * Converts string values to number or boolean when needed
 * @param {object} obj - The object to be processed
 * @returns {object} - A new object with normalized content
 */
export function normalizeObject(obj) {
  const result = {};
  if (obj)
    $.each(obj, (key, value) => {
      let s;
      if (typeof value === 'string' && (s = value.trim().toLowerCase()) !== '')
        value = s === 'true' ? true : s === 'false' ? false : isNaN(s) ? value : Number(s);
      result[key] = value;
    });
  return result;
};

/**
 * Returns an partial clone of an object, containing only the own attributes specified in an array of possible keys.
 * When the value of an attribute is of type 'Object' and this object has a method named `getAttributes`, the result of calling
 * this method is returned instead of the crude object.
 * @param {object} obj - The object to be processed
 * @param {string[]} [keys] - An optional array of keys to be included in the resulting object.
 * When null or not set, all keys of `obj` are included. Keys can include a default value separed by '|'.
 * Attributes with default value will be excluded from the resulting object.
 * @returns {object}
 */
export function getAttr(obj, keys = null) {
  let result = {};
  keys = keys || Object.keys(obj);
  keys.forEach(key => {
    const [k, d] = key.split('|');
    if (obj.hasOwnProperty(k) && typeof obj[k] !== 'undefined' && obj[k] !== null && obj[k].toString() !== d) {
      const v = getValue(obj[k]);
      if (!isEmpty(v))
        result[k] = v;
    }
  });

  // Convert to string objects with only a "text" attribute
  keys = Object.keys(result);
  if (keys.length === 1 && keys[0] === 'text')
    result = result.text;

  return result;
};

/**
 * Gets the minimal representation of the given value (object, array, string, number...)
 * @param {any} value - The value to be processed
 * @returns {any}
 */
export function getValue(value) {
  return value.getAttributes ?
    value.getAttributes() :
    value instanceof Array ?
      value.map(e => getValue(e)) :
      value instanceof Date ?
        value.toISOString() :
        value instanceof Object ?
          getAttr(value) :
          value;
};

/**
 * Checks if the given value is an empty object, null or a zero-length string
 * @param {any} v - The value to be checked
 * @returns {boolean} - `true` if `v` is `{}`, `null` or `""`
 */
export function isEmpty(v) {
  let result = (typeof v === 'undefined' || v === null);
  if (!result) {
    switch (typeof v) {
      case 'object':
        result = Object.keys(v).length === 0;
        break;

      case 'string':
        result = v.length === 0;
        break;
    }
  }
  return result;
};

/**
 * Fills an object with specific attributes from another data object
 * @param {object} obj - The target object
 * @param {object} data - The data object
 * @param {string[]} attr - The list of attributes to be copied from `data` to `obj`
 *                          Elements of this list can be:
 *                          a) Just a string. In this case, the native object will be used as a value
 *                          b) An object with the following members:
 *                          - `key`{string} - The attribute name
 *                          - `fn` {function} - The function to be invoked to build the object
 *                          - `params` {string[]} - Optional params to be passed to the `setAttributes` method of the created object
 *                          - `group` {string} - Used when `data` is an object or an array (possible values are `object` and `array`), and multiple results
 *                                               should be aggregated in a resulting object or array with the same keys (or ordering) as data.
 *                          - `init` {string} - Optional parameter indicating if `fn` should be passed with an additional param. This param can be:
 *                            - `key` - The member's key
 *
 * @returns {object} - Always returns `obj`
 */
export function setAttr(obj, data, attr) {
  attr.forEach(a => {
    if (a.key) {
      const { key, fn, group, init, params } = a;
      // A new object should be built
      if (!isEmpty(data[key])) {
        const dataset = data[key];
        if (group === 'object')
          obj[key] = Object.keys(dataset).reduce((o, k) => {
            o[k] = buildObj(fn, dataset[k], init === 'key' ? k : init, params);
            return o;
          }, {});
        else if (group === 'array')
          obj[key] = dataset.map((element, n) => buildObj(fn, element, init === 'key' ? n : init, params));
        else
          obj[key] = buildObj(fn, dataset, init, params);
      }
    } else if (!isEmpty(data[a]))
      obj[a] = data[a];
  });
  return obj;
};

/**
 * Builds a new object based on the provided constructor, data and initialization value
 * Objects used with this function should implement `setAttributes`, or an static method named `factory`
 * @param {function} objType - A class or function to be invoked to build the object.
 * @param {object} [data] - An optional object filled with the attributes to be assigned to the newly created object.
 * @param {any} [init] - An optional value to be passed to the function when invoked with `new`
 * @param {object[]} [params=[]] - Optional array of params to be passed when calling `setAttributes` on the final object
 * @returns {object} - The resulting object
 */
export function buildObj(objType, data, init, params = []) {
  return objType.factory ? objType.factory(data, init, params) : new objType(init).setAttributes(data, ...params);
};

/**
 * Check if the given char is a separator
 * @param {string} ch - A string with a single character
 * @returns {boolean}
 */
export function isSeparator(ch) {
  return settings.SEPARATORS.includes(ch);
};

/**
 * Check if the given char is a word delimiter
 * @param {string} ch - A string with a single character
 * @returns {boolean}
 */
export function isWordDelimiter(ch) {
  return settings.WORD_DELIMITERS.includes(ch);
}

/**
 * Converts a string in an array of objects with 'text' and 'sep' attributes, where 'text' are single words and 'sep'
 * are the word separators following each word in the sentence.
 * @example
 * stringToWords("Hello, World! That's all") returns:
 * [
 *   {text: "Hello", sep: ", "},
 *   {text: "World", sep: "! "},
 *   {text: "That", sep: "'"},
 *   {text: "s", sep: " "},
 *   {text: "all", sep: ""},
 * ]
 * @param {*} str - The text to be tokenized
 * @returns {object[]}
 */
export function stringToWords(str) {
  const result = [];
  let token = { text: '', sep: '' };
  let inWord = true;
  for (let i = 0; i < str.length; i++) {
    const ch = str.charAt(i);
    const delim = isWordDelimiter(ch);
    if (inWord) {
      if (!delim)
        token.text += ch;
      else {
        inWord = false;
        token.sep = ch;
      }
    } else {
      if (delim)
        token.sep += ch;
      else {
        result.push(token);
        token = { text: ch, sep: '' };
        inWord = true;
      }
    }
  }
  result.push(token);
  return result;
}

/**
 * Rounds `v` to the nearest multiple of `n`
 * @param {number} v
 * @param {number} n - Cannot be zero!
 * @returns {number}
 */
export function roundTo(v, n) {
  return Math.round(v / n) * n;
};

/**
 * Set the maximum number of decimals for a number
 * @param {any} v - The value to be converted to a fixed number of decimals. Can be anything.
 * @param {number} n=4 - the maximum number of decimals
 * @returns {any} - When `v` is a number, a number with fixed decimals is returned. Otherwise, returns `v`
 */
export function fx(v, n = 4) {
  return v.toFixed ? Number(v.toFixed(n)) : v;
};

/**
 * Compares the provided answer against multiple valid options. These valid options are
 * concatenated in a string, separated by pipe chars (`|`). The comparing can be case sensitive.
 * @param {string} answer - The text to check against to
 * @param {string} check - String containing one or multiple options, separated by `|`
 * @param {boolean} [checkCase=false] - When true, the comparing will be case-sensitive
 * @param {boolean} [numeric=false] - When true, we are comparing numeric expressions
 * @returns {boolean}
 */
export function compareMultipleOptions(answer, check, checkCase = false, numeric = false) {
  if (answer === null || answer.length === 0 || check === null || check.length === 0)
    return false;
  if (!checkCase && !numeric)
    answer = answer.toUpperCase();
  answer = answer.trim();

  // Check for numeric digits in answer!
  numeric = numeric && /\d/.test(answer);

  for (let token of check.split('|')) {
    if (numeric) {
      if (Number.parseFloat(answer.replace(/,/, '.')) === Number.parseFloat(token.replace(/,/, '.')))
        return true;
    }
    else if (answer === (checkCase ? token : token.toUpperCase()).trim())
      return true;
  }
  return false;
};

/**
 * Checks if the given string ends with the specified expression
 * @param {string} text - The string where to find the expression
 * @param {string} expr - The expression to search for.
 * @param {boolean} [trim] - When `true`, the `text` string will be trimmed before check
 * @returns {boolean}
 */
export function endsWith(text = '', expr, trim) {
  return typeof text === 'string' && (trim ? text.trim() : text).endsWith(expr);
};

/**
 * Checks if the given string starts with the specified expression
 * @param {string} text - The string where to find the expression
 * @param {string} expr - The expression to search for.
 * @param {boolean} [trim] - When `true`, the `text` string will be trimmed before check
 * @returns {boolean}
 */
export function startsWith(text = '', expr, trim) {
  return typeof text === 'string' && (trim ? text.trim() : text).indexOf(expr) === 0;
};

/**
 * Replaces all occurrences of the backslash character (`\`) by a regular slash (`/`)
 * This is useful to normalize bad path names present in some old JClic projects
 * @param {string} str - The string to be normalized
 * @returns {string}
 */
export function nSlash(str) {
  return str ? str.replace(/\\/g, '/') : str;
};

/**
 * Checks if the given expression is an absolute URL
 * @param {string} exp - The expression to be checked
 * @returns {boolean}
 */
export function isURL(exp) {
  return /^(filesystem:)?(https?|file|data|ftps?):/i.test(exp);
};

/**
 * Gets the base path of the given file path (absolute or full URL). This base path always ends
 * with `/`, meaning it can be concatenated with relative paths without adding a separator.
 * @param {string} path - The full path to be parsed
 * @returns {string}
 */
export function getBasePath(path) {
  const p = path.lastIndexOf('/');
  return p >= 0 ? path.substring(0, p + 1) : '';
};

/**
 * Gets the full path of `file` relative to `basePath`
 * @param {string} file - The file name
 * @param {string} [path] - The base path
 * @returns {string}
 */
export function getRelativePath(file, path) {
  return (!path || path === '' || file.indexOf(path) !== 0) ? file : file.substr(path.length);
};

/**
 * Gets the complete path of a relative or absolute URL, using the provided `basePath`
 * @param {string} basePath - The base URL
 * @param {string} path - The filename
 * @returns {string}
 */
export function getPath(basePath, path) {
  return isURL(path) ? path : basePath + path;
};

/**
 * Gets a promise with the complete path of a relative or absolute URL, using the provided `basePath`
 * @param {string} basePath - The base URL
 * @param {string} path - The filename
 * @param {external:JSZip} [zip] - An optional {@link external:JSZip} object where to look
 * for the file
 * @returns {external:Promise}
 */
export function getPathPromise(basePath, path, zip) {
  if (zip) {
    const fName = getRelativePath(basePath + path, zip.zipBasePath);
    if (zip.files[fName]) {
      return new Promise((resolve, reject) => {
        zip.file(fName).async('base64').then(data => {
          const ext = path.toLowerCase().split('.').pop();
          const mime = settings.MIME_TYPES[ext] || 'application/octet-stream';
          resolve(`data:${mime};base64,${data}`);
        }).catch(reject);
      });
    }
  }
  return Promise.resolve(getPath(basePath, path));
};

/**
 * Utility object that provides several methods to build simple and complex DOM objects
 * @type {object}
 */
export const $HTML = {
  doubleCell: (a, b) => $('<tr/>').append($('<td/>').html(a)).append($('<td/>').html(b)),
  p: txt => $('<p/>').html(txt),
  td: (txt, className) => $('<td/>', className ? { class: className } : null).html(txt),
  th: (txt, className) => $('<th/>', className ? { class: className } : null).html(txt),
};

/**
 * Replaces `width`, `height` and `fill` attributes of a simple SVG image
 * with the provided values
 * @param {string} svg - The SVG image as XML string
 * @param {string} [width] - Optional setting for "width" property
 * @param {string} [height] - Optional setting for "height" property
 * @param {string} [fill] - Optional setting for "fill" property
 * @returns {string} - The resulting svg code
 */
export function getSvg(svg, width, height, fill) {
  if (width)
    svg = svg.replace(/width=\"\d*\"/, `width="${width}"`);
  if (height)
    svg = svg.replace(/height=\"\d*\"/, `height="${height}"`);
  if (fill)
    svg = svg.replace(/fill=\"[#A-Za-z0-9]*\"/, `fill="${fill}"`);
  return svg;
};

/**
 * Encodes a svg expression into a {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/data_URIs|data URI}
 * suitable for the `src` property of `img` elements, optionally changing its original size and fill values.
 * @param {string} svg - The SVG image as XML string
 * @param {string} [width] - Optional setting for "width" property
 * @param {string} [height] - Optional setting for "height" property
 * @param {string} [fill] - Optional setting for "fill" property
 * @returns {string} - The resulting Data URI
 */
export function svgToURI(svg, width, height, fill) {
  return 'data:image/svg+xml;base64,' + window.btoa(getSvg(svg, width, height, fill));
};

/**
 * Converts the given expression into a valid value for CSS size values
 * @param {string|number} exp - The expression to be evaluated. Can be a numeric value, `null` or `undefined`.
 *                              Positive values are in "px" units, negative ones are "%"
 * @param {object} css - An optional Object where the resulting expression (if any) will be saved
 * @param {string} key - The key under which the result will be stored in `css`
 * @param {string} def - Default value to be used when `exp` is `null` or `undefined`
 * @returns {string} - A valid CSS value, or `null` if it can't be found. Default units are `px`
 */
export function toCssSize(exp, css, key, def) {
  const result = typeof exp === 'undefined' || exp === null ? null : isNaN(exp) ? exp : exp < 0 ? `${Math.abs(exp)}%` : `${exp}px`;
  if (css && key && (result || def))
    css[key] = result !== null ? result : def;
  return result;
};

/**
 * Gets a clip of the give image data, in a URL base64 encoded format
 * @param {object} img - The binary data of the realized image, usually obtained from a {@link module:bads/MediaBagElement.MediaBagElement}
 * @param {module:AWT.Rectangle} rect - A rectangle containing the requested clip
 * @returns {string} - The URL with the image clip, as a PNG file encoded in base64
 */
export function getImgClipUrl(img, rect) {
  const canvas = document.createElement('canvas');
  canvas.width = rect.dim.width;
  canvas.height = rect.dim.height;
  const ctx = canvas.getContext('2d');
  let result = '';
  try {
    ctx.drawImage(img, rect.pos.x, rect.pos.y, rect.dim.width, rect.dim.height, 0, 0, rect.dim.width, rect.dim.height);
    result = canvas.toDataURL();
  } catch (err) {
    // catch 'tainted canvases may not be exported' and other errors
    log('error', err);
  }
  return result;
};

/**
 * Finds the nearest `head` or root node of a given HTMLElement, useful to place `<style/>` elements when
 * the main component of JClic is behind a shadow-root.
 * This method will be replaced by a call to [Node.getRootNode()](https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode)
 * when fully supported by all major browsers.
 * @param {external:HTMLElement} [el] - The element from which to start the search
 * @returns {external:HTMLElement}
 */
export function getRootHead(el) {
  if (el) {
    // Skip HTMLElements
    while (el.parentElement)
      el = el.parentElement;
    // Get the parent node of the last HTMLElement
    if (el instanceof HTMLElement)
      el = el.parentNode || el;
    // If the root node has a `head`, take it
    el = el['head'] || el;
  }
  return el || document.head;
};

/**
 * Appends a stylesheet element to the `head` or root node nearest to the given `HTMLElement`.
 * @param {string} css - The content of the stylesheet
 * @param {module:JClicPlayer.JClicPlayer} [ps] - An optional `PlayStation` (currently a {@link module:JClicPlayer.JClicPlayer JClicPlayer}) used as a base to find the root node
 * @returns {external:HTMLStyleElement} - The appended style element
 */
export function appendStyleAtHead(css, ps) {
  const root = getRootHead(ps && ps.$topDiv ? ps.$topDiv[0] : null);
  const style = document.createElement('style');
  style.type = 'text/css';
  style.appendChild(document.createTextNode(css));
  return root.appendChild(style);
};

/**
 * Traverses all the attributes defined in an Element, calling a function with its name and value as a parameters
 * @param {external:NamedNodeMap} attributes - The [Element.attributes](https://developer.mozilla.org/en-US/docs/Web/API/Element/attributes)
 * object to be traversed
 * @param {function} callback - The function to be called for each [Attr](https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap)
 * object. It should take two parametres: `name` and `value`
 */
export function attrForEach(attributes, callback) {
  for (let i = 0; i < attributes.length; i++)
    callback(attributes[i].name, attributes[i].value);
};

/**
 * Recursive traversal of all nodes of the given object looking for children having the `childName` attribute
 * WARNING: Don't call this method on objects with circular dependencies!
 * @param {object} obj       - The object to be analized
 * @param {string} childName - Name of the attribute to search for
 * @returns {object[]}       - Array of children having the searched attribute
 */
export function findParentsWithChild(obj, childName, _result = []) {
  if (obj[childName])
    _result.push(obj);
  else
    Object.values(obj).forEach(val => {
      if (typeof val === 'object')
        findParentsWithChild(val, childName, _result);
    });
  return _result;
};

//
// Functions useful to deal with caret position in `contentEditable` DOM elements
//
/**
 * Gets the caret position within the given element. Thanks to
 * {@link http://stackoverflow.com/users/96100/tim-down|Tim Down} answers in:
 * {@link http://stackoverflow.com/questions/4811822/get-a-ranges-start-and-end-offsets-relative-to-its-parent-container}
 * and {@link http://stackoverflow.com/questions/6240139/highlight-text-range-using-javascript/6242538}
 * @param {object} element - A DOM element
 * @returns {number}
 */
export function getCaretCharacterOffsetWithin(element) {
  let caretOffset = 0;
  const doc = element.ownerDocument || element.document;
  const win = doc.defaultView || doc.parentWindow;
  let sel;
  if (typeof win.getSelection !== "undefined") {
    sel = win.getSelection();
    if (sel.rangeCount > 0) {
      const range = win.getSelection().getRangeAt(0);
      const preCaretRange = range.cloneRange();
      preCaretRange.selectNodeContents(element);
      preCaretRange.setEnd(range.endContainer, range.endOffset);
      caretOffset = preCaretRange.toString().length;
    }
  } else if ((sel = doc.selection) && sel.type !== "Control") {
    const textRange = sel.createRange();
    const preCaretTextRange = doc.body.createTextRange();
    preCaretTextRange.moveToElementText(element);
    preCaretTextRange.setEndPoint("EndToEnd", textRange);
    caretOffset = preCaretTextRange.text.length;
  }
  return caretOffset;
};

/**
 * Utility function called by {@link module:Utils.getCaretCharacterOffsetWithin}
 * @param {object} node - A text node
 * @returns {object[]}
 */
export function getTextNodesIn(node) {
  const textNodes = [];
  if (node.nodeType === 3) {
    textNodes.push(node);
  } else {
    const children = node.childNodes;
    for (let i = 0, len = children.length; i < len; ++i) {
      textNodes.push.apply(textNodes, getTextNodesIn(children[i]));
    }
  }
  return textNodes;
};

/**
 * Sets the selection range (or the cursor position, when `start` and `end` are the same) to a
 * specific position inside a DOM element.
 * @param {object} el - The DOM element where to set the cursor
 * @param {number} start - The start position of the selection (or cursor position)
 * @param {number} end - The end position of the selection. When null or identical to `start`,
 * indicates a cursor position.
 */
export function setSelectionRange(el, start, end) {
  if (isNullOrUndef(end))
    end = start;
  if (document.createRange && window.getSelection) {
    const range = document.createRange();
    range.selectNodeContents(el);
    const textNodes = getTextNodesIn(el);
    let foundStart = false;
    let charCount = 0, endCharCount, textNode;

    for (let i = 0; i < textNodes.length; i++) {
      textNode = textNodes[i];
      endCharCount = charCount + textNode.length;
      if (!foundStart && start >= charCount &&
        (start < endCharCount ||
          start === endCharCount && i + 1 <= textNodes.length)) {
        range.setStart(textNode, start - charCount);
        foundStart = true;
      }
      if (foundStart && end <= endCharCount) {
        range.setEnd(textNode, end - charCount);
        break;
      }
      charCount = endCharCount;
    }
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  } else if (document.selection && document.body.createTextRange) {
    const textRange = document.body.createTextRange();
    textRange.moveToElementText(el);
    textRange.collapse(true);
    textRange.moveEnd('character', end);
    textRange.moveStart('character', start);
    textRange.select();
  }
};

/**
 * Performs multiple replacements on the provided string
 * See: https://stackoverflow.com/questions/2501435/replacing-multiple-patterns-in-a-block-of-data
 * @param {Object[]} replacements - Array of pairs formed by an "expression" (regexp or string) and a "value" (string) to replace the fragments found
 * @param {String} str - The string to be checked for replacements
 * @returns {String} - The original string with the fragments found already replaced
 */
export function mReplace(replacements, str) {
  return replacements.reduce((result, [exp, replacement]) => result.replace(exp, replacement), str);
};

/**
 * Global constants
 * @const
 */
export const settings = {
  // JClic.js Version
  VERSION: GlobalData.version,
  // Check if we are running on NodeJS with JSDOM
  NODEJS: window.name === 'nodejs',
  // layout constants
  AB: 0, BA: 1, AUB: 2, BUA: 3,
  LAYOUT_NAMES: ['AB', 'BA', 'AUB', 'BUA'],
  DEFAULT_WIDTH: 400,
  DEFAULT_HEIGHT: 300,
  MINIMUM_WIDTH: 40,
  MINIMUM_HEIGHT: 40,
  DEFAULT_NAME: '---',
  DEFAULT_MARGIN: 8,
  DEFAULT_SHUFFLES: 31,
  DEFAULT_GRID_ELEMENT_SIZE: 20,
  MIN_CELL_SIZE: 10,
  //DEFAULT_BG_COLOR: '#D3D3D3', // LightGray
  DEFAULT_BG_COLOR: '#C0C0C0', // LightGray
  ACTIONS: {
    ACTION_MATCH: 'MATCH', ACTION_PLACE: 'PLACE',
    ACTION_WRITE: 'WRITE', ACTION_SELECT: 'SELECT', ACTION_HELP: 'HELP'
  },
  PREVIOUS: 0, MAIN: 1, END: 2, END_ERROR: 3, NUM_MSG: 4,
  MSG_TYPE: ['previous', 'initial', 'final', 'finalError'],
  RANDOM_CHARS: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
  NUM_COUNTERS: 3,
  MAX_RECORD_LENGTH: 180,
  // BoxBase defaults
  BoxBase: {
    REDUCE_FONT_STEP: 1.0,
    MIN_FONT_SIZE: 8,
    STROKE: 1,
    AC_MARGIN: 6,
    //BACK_COLOR: 'lightgray',
    BACK_COLOR: '#C0C0C0',
    TEXT_COLOR: 'black',
    SHADOW_COLOR: 'gray',
    INACTIVE_COLOR: 'gray',
    ALTERNATIVE_COLOR: 'gray',
    BORDER_COLOR: 'black',
    BORDER_STROKE_WIDTH: 0.75,
    MARKER_STROKE_WIDTH: 2.75
  },
  FILE_TYPES: {
    image: 'gif,jpg,png,jpeg,bmp,ico,svg',
    audio: 'wav,mp3,ogg,oga,au,aiff,flac',
    video: 'avi,mov,mpeg,mp4,ogv,m4v,webm',
    font: 'ttf,otf,eot,woff,woff2',
    midi: 'mid,midi',
    anim: 'swf',
    // Used in custom skins
    xml: 'xml'
  },
  MIME_TYPES: {
    xml: 'text/xml',
    gif: 'image/gif',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    png: 'image/png',
    bmp: 'image/bmp',
    svg: 'image/svg+xml',
    ico: 'image/x-icon',
    wav: 'audio/wav',
    mp3: 'audio/mpeg',
    mp4: 'video/mp4',
    m4v: 'video/mp4',
    ogg: 'audio/ogg',
    oga: 'audio/ogg',
    ogv: 'video/ogg',
    webm: 'video/webm',
    au: 'audio/basic',
    aiff: 'audio/x-aiff',
    flac: 'audio/flac',
    avi: 'video/avi',
    mov: 'video/quicktime',
    mpeg: 'video/mpeg',
    ttf: 'application/font-sfnt',
    otf: 'application/font-sfnt',
    eot: ' application/vnd.ms-fontobject',
    woff: 'application/font-woff',
    woff2: 'application/font-woff2',
    swf: 'application/x-shockwave-flash',
    mid: 'audio/midi',
    midi: 'audio/midi'
  },
  // Global settings susceptible to be modified
  COMPRESS_IMAGES: true,
  // Keyboard key codes
  VK: {
    LEFT: 37,
    UP: 38,
    RIGHT: 39,
    DOWN: 40
  },
  // Flag to indicate that we are running on a touch device
  TOUCH_DEVICE: false,
  // Amount of time (in milliseconds) to wait before a media resource is loaded
  LOAD_TIMEOUT: 10000,
  // Number of points to be calculated as polygon vertexs when simplifying bezier curves
  BEZIER_POINTS: 4,
  // Check if canvas accessibility features are enabled
  // See: http://codepen.io/francesc/pen/amwvRp
  // UPDATED May 2020: Detection removed since Canvas HitRegions have been deprecated
  // See: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Hit_regions_and_accessibility
  //
  // CANVAS_HITREGIONS: typeof CanvasRenderingContext2D !== 'undefined' && typeof CanvasRenderingContext2D.prototype.addHitRegion === 'function',
  // CANVAS_HITREGIONS_FOCUS: typeof CanvasRenderingContext2D !== 'undefined' && typeof CanvasRenderingContext2D.prototype.drawFocusIfNeeded === 'function',
  //
  CANVAS_DRAW_FOCUS: typeof window.CanvasRenderingContext2D !== 'undefined' && typeof window.CanvasRenderingContext2D.prototype.drawFocusIfNeeded === 'function',
  // See: https://emptycharacter.com/
  // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Character_Classes
  WHITESPACES: '  \f\n\r\t\v\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202f\u205f\u3000\ufeff',
};
settings.SEPARATORS = `${settings.WHITESPACES}.,;-|`;
settings.WORD_DELIMITERS = `${settings.SEPARATORS}…_<>"“”«»'\xB4\x60\u2018\u2019\u2022~+\u2013\u2014\u2015=%¿?¡!:/\\()[]{}$£€`;

/**
 * Miscellaneous utility functions and constants
 */
export const Utils = {
  pkg,
  settings,
  getMsg,
  LOG_LEVELS,
  LOG_PRINT_LABELS,
  LOG_OPTIONS,
  init,
  setLogLevel,
  log,
  getBoolean,
  getVal,
  getNumber,
  getPercent,
  zp,
  getHMStime,
  getDateTime,
  parseOldDate,
  cleanOldLanguageTag,
  FALSE,
  TRUE,
  DEFAULT,
  getTriState,
  fillString,
  isNullOrUndef,
  isEquivalent,
  getXmlText,
  parseXmlNode,
  getXmlNodeText,
  reduceTextsToStrings,
  cssToString,
  checkColor,
  colorHasTransparency,
  cloneObject,
  normalizeObject,
  getAttr,
  getValue,
  isEmpty,
  setAttr,
  buildObj,
  isSeparator,
  isWordDelimiter,
  stringToWords,
  roundTo,
  fx,
  compareMultipleOptions,
  endsWith,
  startsWith,
  nSlash,
  isURL,
  getBasePath,
  getRelativePath,
  getPath,
  getPathPromise,
  $HTML,
  getSvg,
  svgToURI,
  toCssSize,
  getImgClipUrl,
  getRootHead,
  appendStyleAtHead,
  attrForEach,
  findParentsWithChild,
  getCaretCharacterOffsetWithin,
  getTextNodesIn,
  setSelectionRange
};

export default Utils;