string_utils.js

/** @module */
import Item from "./models/item.js";
import { random } from "./random_utils.js";

const CONS_Y = /[BCDFGHIJKLMNPQRSTVWXZbcdfghijklmnpqrstvwxz]y$/;
const FORMAT_PARSER = /\{[^\|}]+\}/g;
const PARENS = /(\{[^\}]*\})/g;
const SMALL_WORDS = /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|vs?\.?|via)$/i;
const STARTS_WITH_THE = /^[tT]he\s/;
const STARTS_WITH_VOWEL = /^[aeiouAEIOU]/;
const SLICE = Array.prototype.slice;

function basicPluralize(string) {
  if (/[x|s]$/.test(string)) {
    return string + "es";
  } else if (CONS_Y.test(string)) {
    return string.substring(0, string.length - 1) + "ies";
  }
  return string + "s";
}

/**
 * Format a string with parameters. There are many ways to supply values to this method:
 *
 * @example
 * format('This {0} a {1}.', ['is', 'test']);
 * => "This is a test."
 * format('This {0} a {1}.', 'is', 'test');
 * => "This is a test."
 * format('This {verb} a {noun}.', {verb: 'is', noun: 'test'})
 * => "This is a test."
 *
 * @param template {String} template string
 * @param values+ {Object} An array, a set of values, or an object with key/value pairs that
 *    will be substituted into the template.
 * @return {String} the formatted string
 */
export function format(string, obj) {
  if (typeof obj == "undefined") {
    return string;
  }
  if (arguments.length > 2 || typeof obj !== "object") {
    obj = Array.from(arguments);
    string = obj.shift();
  }
  // Selects {a} sequences with no pipe (these are multiple selection strings, not substitutions)
  return string.replace(FORMAT_PARSER, function (token) {
    var prop = token.substring(1, token.length - 1);
    return typeof obj[prop] == "function" ? obj[prop]() : obj[prop];
  });
}

/**
 * Convert string to title case. There's a long list of rules for this
 * kind of capitalization, see:
 *
 * *To Title Case 2.1 - http://individed.com/code/to-title-case/<br>
 * Copyright 2008-2013 David Gouch. Licensed under the MIT License.*
 *
 * [0]: http://daringfireball.net/2008/05/title_case
 *
 * @example
 * titleCase('antwerp benedict');
 * => "Antwerp Benedict"
 * titleCase('antwerp-Benedict');
 * => "Antwerp-Benedict"
 * titleCase('bead to a small mouth');
 * => "Bead to a Small Mouth"
 *
 * @param string {String} string to title case
 * @return {String} in title case
 */
export function titleCase(string) {
  return string.replace(/[A-Za-z0-9\u00C0-\u00FF]+[^\s-]*/g, function (match, index, title) {
    if (
      index > 0 &&
      index + match.length !== title.length &&
      match.search(SMALL_WORDS) > -1 &&
      title.charAt(index - 2) !== ":" &&
      (title.charAt(index + match.length) !== "-" || title.charAt(index - 1) === "-") &&
      title.charAt(index - 1).search(/[^\s-]/) < 0
    ) {
      return match.toLowerCase();
    }
    if (match.substr(1).search(/[A-Z]|\../) > -1) {
      return match;
    }
    return match.charAt(0).toUpperCase() + match.substr(1);
  });
}

/**
 * Format the elements of an array into a list phrase.
 *
 * @example
 * toList(['Apples', 'Bananas', 'Oranges'], (value) => '*'+value);
 * => "*Apples, *Bananas, and *Oranges"
 *
 * @param array {Array} The array to format
 * @param [func=identity] {Function} An optional function to format the elements of the array in the returned string.
 * @param [join=and] {String} the word to join the last word in the list.
 * @param [separator=,] {String} the delimiter to separate items in the list.
 * @return {String} the array formatted as a list.
 */
export function toList(array, func = (s) => s.toString(), join = "and", separator = ", ") {
  let len = array.length;
  if (len === 0) {
    return "";
  } else if (len === 1) {
    return func(array[0]);
  } else if (len === 2) {
    return `${func(array[0])} ${join} ${func(array[1])}`;
  } else {
    var arr = array.map(func);
    arr[arr.length - 1] = join + " " + arr[arr.length - 1];
    return arr.join(separator);
  }
}

/**
 * Convert a string to sentence case (only the first letter capitalized).
 *
 * @example
 * sentenceCase('antwerp benedict');
 * => "Antwerp benedict"
 * sentenceCase('antwerp-Benedict');
 * => "Antwerp-benedict"
 * sentenceCase('bead to a small mouth');
 * => "Bead to a small mouth"
 *
 * @param string {String}
 * @return {String} in sentence case
 */
export function sentenceCase(string) {
  if (typeof string === "string") {
    return string.substring(0, 1).toUpperCase() + string.substring(1);
  }
  return string;
}

/**
 * Pluralizes a string (usually a noun), if the count is greater than one. If
 * it's a single item, an indefinite article will be added (see example below
 * for cases where it should not be added, "uncountables"). The string should
 * note the method of pluralizing the string in curly braces if it is not a
 * simple noun that is pluralized using "s", "es" or "aries". For example:
 *
 * @example
 * pluralize('shoe', 3)
 * => "3 shoes"
 * pluralize('status', 2)
 * => "2 statuses"
 * pluralize('bag{s} of flour', 1)
 * => "a bag of flour"
 * pluralize('bag{s} of flour', 2)
 * => "2 bags of flour"
 * // Note suppression of the indefinite article!
 * pluralize('{|suits of }makeshift metal armor')
 * => "makeshift metal armor"
 * pluralize('{|suits of }makeshift metal armor', 4)
 * => "4 suits of makeshift metal armor"
 * let item = new Item('quarry');
 * pluralize(item, 3)
 * => "3 quarries"
 *
 * @param name {String|Item} A string name following the rules described above, or an Item
 *    with a name property
 * @param [count=1] {Number} The number of these items
 * @return {String} the correct singular or plural name
 */
export function pluralize(string, count = 1) {
  string = string.name ? string.name : string;
  let obj = { singular: "", plural: "" };
  let addArticle = string.substring(0, 2) !== "{|";

  if (count > 1) {
    obj.plural += count + " ";
  }
  if (string.indexOf("{") === -1) {
    obj.singular = string;
    obj.plural += basicPluralize(string);
  } else {
    string.split(PARENS).forEach(function (element, index) {
      if (element.indexOf("{") === -1) {
        obj.singular += element;
        obj.plural += element;
      } else if (element.indexOf("|") === -1) {
        obj.plural += element.substring(1, element.length - 1);
      } else {
        let parts = element.substring(1, element.length - 1).split("|");
        obj.singular += parts[0];
        obj.plural += parts[1];
      }
    });
  }
  if (addArticle) {
    obj.singular = article(obj.singular);
  }
  return count === 1 ? obj.singular : obj.plural;
}

export function uncountable(string) {
  string = string.name ? string.name : string;
  let plural = "";

  if (string.indexOf("{") === -1) {
    plural += basicPluralize(string);
  } else {
    string.split(PARENS).forEach((element) => {
      if (element.indexOf("{") === -1) {
        plural += element;
      } else if (element.indexOf("|") === -1) {
        plural += element.substring(1, element.length - 1);
      } else {
        let parts = element.substring(1, element.length - 1).split("|");
        plural += parts[1];
      }
    });
  }
  return plural;
}

/**
 * Put an indefinite article in front of the word based on whether or not it
 * starts with a vowel.
 *
 * @example
 * article('walkie talkie')
 * => "a walkie talkie"
 * article('album')
 * => "an album"
 *
 * @param string {String} String to prefix with an indefinite article
 * @return {String} The string with "a" or "an" in front of it.
 */
export function article(string) {
  return STARTS_WITH_THE.test(string)
    ? string
    : STARTS_WITH_VOWEL.test(string)
    ? "an " + string
    : "a " + string;
}

/**
 * Combines randomization with formatting. First, randomizes the first argument using
 * the `random()` function. Then formats the resulting string using the rest of the
 * arguments passed in to the method, as described by the `format()` function.
 *
 *     resolve(["Mr. {name}", "Mrs. {name}"], {name: "Smith"});
 *     => "Mrs. Smith"
 *
 * @method resolve
 *
 * @param value {String|Array|Function} A string with optional variants, or an array from
 *      which to select an element, or a function that returns a value.
 * @param [...args] zero or more objects to use in formatting the final string
 * @return {String} the randomly selected, formatted string
 */
export function resolve() {
  if (arguments.length === 1) {
    return random(arguments[0]);
  }
  var array = SLICE.call(arguments, 0);
  array[0] = random(array[0]);
  return format.apply(format, array);
}

// Weirdly, you need all this to serialize correctly...
export function toJSON(obj) {
  return JSON.stringify(obj, (key, value) => {
    if (value instanceof Set) {
      return Array.from(value);
    } else if (value instanceof Map) {
      return Object.fromEntries(value);
    }
    return value;
  });
}

export function parseJSON(json) {
  return JSON.parse(json, (key, value) => {
    if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/.test(value)) {
      return new Date(value);
    }
    return value;
  });
}

export class Builder {
  constructor(joiner = "") {
    this.buffer = [];
    this.joiner = joiner;
  }
  if(expr, trueCase, falseCase) {
    this.#addToBuffer(expr, trueCase, falseCase, "push");
  }
  preIf(expr, trueCase, falseCase) {
    this.#addToBuffer(expr, trueCase, falseCase, "unshift");
  }
  #addToBuffer(expr, trueCase, falseCase, operation) {
    if (expr) {
      if (typeof trueCase === "function") {
        this.buffer[operation](trueCase());
      } else {
        this.buffer[operation](trueCase);
      }
    } else if (falseCase) {
      if (typeof falseCase === "function") {
        this.buffer[operation](falseCase());
      } else {
        this.buffer[operation](falseCase);
      }
    }
  }
  prepend(output) {
    this.buffer.unshift(output);
  }
  append(output) {
    this.buffer.push(output);
  }
  get length() {
    return this.buffer.length;
  }
  toString() {
    return this.buffer.join(this.joiner);
  }
}

export function toTag(name) {
  return (
    pluralize(name.split(";")[0].trim(), 1)
      .toLowerCase()
      .replaceAll(/&/g, "and")
      .replaceAll(/\s/g, "-")
      .replaceAll(/^an-/g, "")
      .replaceAll(/^a-/g, "")
      .replaceAll(/^\w+-of-/g, "")
      /*
    .replaceAll(/^box-of-/g, "")
    .replaceAll(/^jar-of-/g, "")
    .replaceAll(/^pair-of-/g, "")
    .replaceAll(/^can-of-/g, "")
    .replaceAll(/^set-of-/g, "")
    .replaceAll(/^roll-of-/g, "")
    .replaceAll(/^bottle-of-/g, "")
    */
      .replaceAll(/[^a-zA-Z0-9-]/g, "")
  );
}

export function traitsToString(traits) {
  return Object.keys(traits)
    .sort()
    .map((key) => `${key} ${traits[key]}`)
    .join(", ");
}

/**
 * Parses a spec string in this format: "Bag($N tag1 tag2)" into the parameters
 * to generate a bag through the newCreateBag method. The components include a total
 * value for the bag, and a tag expresion.
 *
 * @param {String} spec
 * @returns {Object} the parameters, incuding “value” and “tags”
 */
export function bagSpecParser(spec) {
  if (!spec.startsWith("Bag(") && !spec.startsWith("Stockpile(")) {
    return {};
  }
  spec = spec.match(/\((.*)\)/)[1];
  let tags, value;
  if (spec.startsWith("$")) {
    [value, ...tags] = spec.split(" ");
    return {
      value: value.substring(1),
      totalValue: value.substring(1),
      tags: tags.join(" "),
      cluster: "high",
    };
  }
  return { tags: spec, cluster: "high" };
}

export function startLowercase(string) {
  return string.substring(0, 1).toLowerCase() + string.substring(1);
}