Str/Str.js

import _ from 'lodash';
import numberToWords from './numberToWords';

/**
 * String utilities
 * @namespace Str
 */

/**
 * Inverts the case of a string
 * @example
 * Str.invertCase('Hello iPhone'); // => 'hELLO IpHONE'
 * @memberof Str
 * @param {string} str
 * @return {string}
 */
function invertCase(str) {
	let output = '';
	let code = '';

	for (let i = 0, len = str.length - 1; i <= len; i++) {
		code = str.charCodeAt(i);
		if (code >= 65 && code <= 90) {
			output += str.charAt(i).toLowerCase();
		}
		else if (code >= 97 && code <= 122) {
			output += str.charAt(i).toUpperCase();
		}
		else {
			output += str.charAt(i);
		}
	}

	return output;
}

/**
 * is the character given is a vowel?
 * @example
 * Str.isVowel('a') // => true
 * Str.isVowel('f') // => false
 * Str.isVowel('ae') // => false
 * @memberof Str
 * @param {string} char
 * @return {boolean}
 */
function isVowel(char) {
	return (/^[aeiou]$/i).test(char);
}

/**
 * is the character given is a consonant?
 * @example
 * Str.isConsonant('a') // => false
 * Str.isConsonant('f') // => true
 * Str.isConsonant('ff') // => false
 * @memberof Str
 * @param {string} char
 * @return {boolean}
 */
function isConsonant(char) {
	return (/^[bcdfghjklmnpqrstvwxys]$/i).test(char);
}

/**
 * Get the plural of a string
 * @memberof Str
 * @param {string} str
 * @return {string}
 */
function plural(str) {
	if (!str || str.length <= 2) return str;

	if (str.charAt(str.length - 1) === 'y') {
		if (isVowel(str.charAt(str.length - 2))) {
			// If the y has a vowel before it (i.e. toys), then you just add the s.
			return str + 's';
		}

		// If a this ends in y with a consonant before it (fly)
		// you drop the y and add -ies to make it plural.
		return str.slice(0, -1) + 'ies';
	}
	if (str.substring(str.length - 2) === 'us') {
		// ends in us -> i, needs to preceed the generic 's' rule
		return str.slice(0, -2) + 'i';
	}
	if (
		['ch', 'sh'].indexOf(str.substring(str.length - 2)) !== -1 ||
		['x', 's'].indexOf(str.charAt(str.length - 1)) !== -1
	) {
		// If a this ends in ch, sh, x, s, you add -es to make it plural.
		return str + 'es';
	}

	// anything else, just add s
	return str + 's';
}

/**
 * Pluralize a character if the count is greater than 1
 * @memberof Str
 * @param {string} str
 * @param {number} [count=2]
 * @return {string}
 */
function pluralize(str, count = 2) {
	if (count <= 1) return str;
	return plural(str);
}

/**
 * transform a string by replacing characters from from string to to string
 * @example
 * Str.transform('abc', 'bc', 'de') // => 'ade'
 * @memberof Str
 * @param {string} str string to transform
 * @param {string} from characters to replace in the string
 * @param {string} to characters to replace with in the string
 * @return {string} transformed string
 */
function transform(str, from, to) {
	const len = str.length;
	let out = '';
	let pos;

	for (let i = 0; i < len; i++) {
		pos = from.indexOf(str.charAt(i));
		if (pos >= 0) {
			out += to.charAt(pos);
		}
		else {
			out += str.charAt(i);
		}
	}

	return out;
}

/**
 * Break String From Next Given Character After A Given Position
 * @memberof Str
 * @param {string} str
 * @param {number} pos
 * @param {string} [char=' ']
 * @return {string}
 */
function trimToNext(str, pos, char = ' ') {
	const trimPos = str.indexOf(char, pos);
	if (trimPos !== -1) {
		return _.trimEnd(str.substring(0, trimPos), char);
	}

	return _.trimEnd(str, char);
}

const numberLocaleCache = {};
function _getNumberLocale(options) {
	const {currency = undefined, decimals = 0} = options;

	// If currency is INR, there should not be space between currency symbol and number
	const locale = (currency === 'INR' ? 'hi-IN' : options.locale) || 'en';
	const localeKey = `${locale}${currency}${decimals}`;

	if (!(localeKey in numberLocaleCache)) {
		numberLocaleCache[localeKey] = new Intl.NumberFormat(locale, {
			style: currency ? 'currency' : 'decimal',
			currency,
			minimumFractionDigits: decimals,
			maximumFractionDigits: decimals,
		});
	}

	return numberLocaleCache[localeKey];
}

// Countries where Indian Numbering System is used
const indianNumberSystem = [
	'in',	// India
	'mm', 	// Myanmar
	'lk', 	// Sri Lanka
	'bd', 	// Bangladesh
	'np', 	// Nepal
	'pk', 	// Pakistan
];

/**
 * Units should be in desc order of values
 * Key should have suffix 'in' if using indian number system
 */
const abbreviateUnits = {
	longin: {
		' Arab': 1e9,
		' Crore': 1e7,
		' Lacs': 1e5,
	},
	long: {
		' Trillion': 1e12,
		' Billion': 1e9,
		' Million': 1e6,
	},
	shortin: {
		' Arab': 1e9,
		' Cr': 1e7,
		' L': 1e5,
	},
	short: {
		T: 1e12,
		B: 1e9,
		M: 1e6,
	},
	autoin: {
		' Arab': 1e9,
		' Cr': 1e7,
		' Lacs': 1e5,
	},
	auto: {
		' Tn': 1e12,
		' Bn': 1e9,
		' Mn': 1e6,
	},
};

/**
 * @typedef {Object} abbreviateOpts
 * @property {number} number
 * @property {string} [unit]
 * @property {number} [decimals]
 */

/**
 * Abbreviate number
 * @private
 * @param {number} number
 * @returns {abbreviateOpts}
 */
function _abbreviateNumber(number, options) {
	const {locale = 'en', abbr = 'none'} = options;
	const result = {number};
	if (!['long', 'short', 'auto'].includes(abbr)) return result;

	const countryCode = (locale.split('-')[1] || '').toLowerCase();
	const numberSystem = (indianNumberSystem.includes(countryCode)) ? 'in' : '';
	const abbrKey = `${abbr}${numberSystem}`;

	for (const unit of Object.keys(abbreviateUnits[abbrKey])) {
		if (abbreviateUnits[abbrKey][unit] <= number) {
			result.number = number / abbreviateUnits[abbrKey][unit];
			result.unit = unit;
			result.decimals = result.number % 1 ? 2 : 0;
			break;
		}
	}
	return result;
}

/**
 * @typedef {object} numberFormatOpts
 * @property {string} locale like 'en-IN'
 * @property {string} currency like 'INR'
 * @property {number} decimals number of decimal places to return
 * @property {number} abbr option to abbreviate number: ['none', 'auto', 'long', 'short']
 */

/**
 * Format a number according to a particular locale
 * Similar to Number.toLocaleFormat, except being significantly faster
 *
 * @memberof Str
 * @param {number} number the number to format
 * @param {numberFormatOpts|string} [options={}]
 * 	string of locale or options object {locale: 'en', decimals: 0, currency: 'INR', abbr: 'auto'}
 * @return {string} formatted number
 */
function numberFormat(number, options = {}) {
	if (typeof options === 'string') {
		options = {locale: options};
	}
	const abbreviatedNumber = _abbreviateNumber(number, options);
	number = abbreviatedNumber.number;
	options.decimals = abbreviatedNumber.decimals || options.decimals;
	number = _getNumberLocale(options).format(number);
	if (abbreviatedNumber.unit) {
		number = number.replace(
			/[0-9,]+\.?[0-9]*/,
			value => `${value}${abbreviatedNumber.unit}`
		);
	}
	return number;
}

/**
 * Space clean a string
 * Converts consecutive multiple spaces / tabs / newlines in the string into a single space
 * @memberof Str
 * @param {string} str
 * @return {string}
 */
function spaceClean(str) {
	if (!str) return '';
	return str.replace(/(?:\s|&nbsp;|&#32;)+/ig, ' ').trim();
}

/**
 * Rotate a string by 13 characters
 *
 * @memberof Str
 * @param {string} str the string to be rotated
 * @return {string} rotated string
 */
function rot13(str) {
	const s = [];
	for (let i = 0; i < str.length; i++) {
		const j = str.charCodeAt(i);
		if (((j >= 65) && (j <= 77)) || ((j >= 97) && (j <= 109))) {
			s[i] = String.fromCharCode(j + 13);
		}
		else if (((j >= 78) && (j <= 90)) || ((j >= 110) && (j <= 122))) {
			s[i] = String.fromCharCode(j - 13);
		}
		else {
			s[i] = String.fromCharCode(j);
		}
	}

	return s.join('');
}

/**
 * Rotate a string by 47 characters
 *
 * @memberof Str
 * @param {string} str the string to be rotated
 * @return {string} rotated string
 */
function rot47(str) {
	const s = [];
	for (let i = 0; i < str.length; i++) {
		const j = str.charCodeAt(i);
		if ((j >= 33) && (j <= 126)) {
			s[i] = String.fromCharCode(33 + ((j + 14) % 94));
		}
		else {
			s[i] = String.fromCharCode(j);
		}
	}

	return s.join('');
}

/**
 * Parses a json string, returns null if string is invalid (instead of throwing error)
 * If the input is not a string (already parsed), returns the input itself
 *
 * @memberof Str
 * @param {any} str
 * @return {object|null}
 */
function tryParseJson(str) {
	if (str == null) return null;
	if (typeof str !== 'string') return str;

	try {
		return JSON.parse(str);
	}
	catch (e) {
		return null;
	}
}

/**
 * Stringifies an object only if it is not already a string
 * If it is already a string returns the string itself
 * If it is undefined, returns 'null'
 *
 * @memberof Str
 * @param {any} obj
 * @returns {string}
 */
function tryStringifyJson(obj) {
	if (obj == null) return 'null';
	if (typeof obj === 'string') return obj;
	return JSON.stringify(obj);
}

/**
 * Strip html tags from a string
 * @memberof Str
 * @param {string} str the string to remove tags from
 * @param {object} options object containing:
 * 	allowed: array of allowed tags eg. ['p', 'b', 'span'], default: []
 * 	blocked: array of blocked tags eg. ['p'], default: []
 * 	replaceWith: replace the removed tags with this string, default: ''
 *
 * if allowed is not given and blocked is given
 * then by default all tags not mentioned in blocked are allowed
 *
 * @return {string} resulting string by removing all tags mentioned
 */
function stripTags(str, options = {}) {
	if (!str) return '';

	const tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi;
	const commentsAndPhpTags = /<!--[\s\S]*?-->|<\?(?:php)?[\s\S]*?\?>/gi;

	const replaceWith = options.replaceWith || '';
	const allowed = options.allowed;
	const blocked = options.blocked;

	const replaceTags = function ($0, $1) {
		if (blocked) {
			// tag is blocked
			if (blocked.includes($1.toLowerCase())) return replaceWith;
			// allowed is not given and blocked is, that means all tags are allowed
			if (!allowed) return $0;
		}
		// tag is allowed
		if (allowed && allowed.includes($1.toLowerCase())) return $0;
		// by default all tags are blocked
		return replaceWith;
	};

	let after = String(str);
	// eslint-disable-next-line no-constant-condition
	while (true) {
		const before = after;
		after = before
			.replace(commentsAndPhpTags, replaceWith)
			.replace(tags, replaceTags);

		// return once no more tags are removed
		if (before === after) {
			return after;
		}
	}
}

/**
 * Escape a string for including in regular expressions
 * @memberof Str
 * @param {string} str string to escape
 * @return {string} escaped string
 */
function escapeRegex(str) {
	if (!str) return '';
	return String(str).replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '\\$&');
}

/**
 * replace special characters within a string
 * NOTE: it replaces multiple special characters with single replaceWith character
 * @param {string} str
 * @param {string} replaceWith
 * @returns {string}
 */
function replaceSpecialChars(str, replaceWith = '') {
	const result = str.replace(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/\s]+/g, replaceWith);
	if (replaceWith) {
		return _.trim(result, replaceWith);
	}
	return result;
}

export default {
	invertCase,
	isVowel,
	isConsonant,
	plural,
	pluralize,
	transform,
	trimToNext,
	numberFormat,
	numberToWords,
	spaceClean,
	rot13,
	rot47,
	tryParseJson,
	tryStringifyJson,
	stripTags,
	escapeRegex,
	replaceSpecialChars,
};