/**
* @module kongUtilString
*/
import utilString from "./core.mjs";
export * from "./core.mjs";
/**
* @func camelize
* @desc Convert kebab-case string into camelCase.
* @param {string} kebab
* @returns {string}
*/
export function camelize(kebab) {
return kebab.replace(
/-([a-z]\w+)/g,
m => m[1].toUpperCase() + m.slice(2)
);
}
/**
* @func kebabize
* @desc Convert camelCase string into kebab-case
* @see {@link https://stackoverflow.com/a/67243723/1998874} CC BY-SA 4.0
* @param {string} camel
* @returns {string}
*/
export function kebabize(camel) {
return camel.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (m, p1) => (p1 ? "-" : "") + m.toLowerCase());
}
/**
* @func parseChineseNumber
* @desc
* Both traditional and simplified chinese numbers are supported.
* Not guaranteed to validate the string.
* Returns string if the number reaches `Number.MAX_SAFE_INTEGER` or `forceString` is given true.
* @param {string} string
* @param {boolean} [forceString = false]
* @returns {number|string|NaN}
*/
export function parseChineseNumber(string, forceString = false) {
let signed = '';
let str = string.toString()
.replaceAll(/\s/g, '')
.replace(/[點点奌]/, '.')
;
if ('負负'.includes(str.charAt(0))) signed = '-';
else if (str.startsWith('正')) signed = '+';
if (signed) str = str.substring(1);
digitRegExps.forEach((re, d) => {
str = str.replaceAll(re, d.toString());
});
if (/^\d+(\.\d+)?$/.test(str)) str = signed + str;
else {
let error = false, isFraction = false;
let reverse = [], integer = [], fraction = [];
let digit = null;
str.split('').forEach(c => {
if (isFraction) return fraction.unshift(c);
if ('0123456789'.includes(c)) return digit = c;
const ten = ['十拾什', '百佰', '千仟'].findIndex(ts => ts.includes(c)) + 1;
if (ten) {
integer[ten] = digit ?? '1';
return digit = null;
}
const wan = ['萬万', '億亿', '兆', '京經经', '垓', '秭杼', '穰壤', '溝沟', '澗涧', '正', '載', '極']
.findIndex(ws => ws.includes(c)) + 1;
if (wan || c === '.') {
integer[0] = digit;
for (let i = 0; i < integer.length; ++i)
reverse[i + wan * 4] = integer[i];
digit = null;
}
if (wan) return integer = [];
if (c === '.') return isFraction = true;
error = true;
});
if (error) return NaN;
if (isFraction) reverse.unshift(...fraction, '.');
else {
integer[0] = digit;
for (let i = 0; i < integer.length; ++i) reverse[i] = integer[i];
}
for (let i = 0; i < reverse.length; ++i) // `Array.forEach()` and `Array.map()` do not iterate empty elements.
if (! reverse[i]) reverse[i] = '0';
str = signed + reverse.reverse().join('');
}
if (forceString) return str;
return Number.isSafeInteger(parseInt(str)) ? parseFloat(str) : str;
// MDN: JavaScript does not have the distinction of "floating point numbers" and "integers" on the language level.
}
const digitRegExps = [
"0零○〇洞",
"1一壹ㄧ弌么",
"2二貳贰弍兩两",
"3三參叁弎参叄",
"4四肆䦉刀",
"5五伍",
"6六陸陆",
"7七柒拐",
"8八捌杯",
"9九玖勾"
].map(d => new RegExp(`[${d}]`, 'g'));
/**
* @func compareVersionNumbers
* @desc
* This only compares dot-separated-integer strings.
* For more complicated cases, use `semver` or `compare-versions`.
* @param {string} a
* @param {string} b
* @returns {integer}
*/
export function compareVersionNumbers(a, b) {
[a, b] = [a, b].map(str => str.split("."));
for (let d in a) {
if (typeof b[d] === "undefined") return 1;
const ad = parseInt(a[d], 10), bd = parseInt(b[d], 10);
if (ad > bd) return 1;
if (ad < bd) return -1;
}
if (a.length < b.length) return -1;
return 0;
}
/**
* @func toCSV
* @desc
* Convert an array of non-nested objects to a `text/csv` string with a header record.
* @param {Array.<Object>} dataArray - properties with keys not present in `fieldNames` would not be converted.
* @param {Array.<string>} fieldNames - Only fields listed here would be in the result.
* @param {string} [eol = \r\n] - end of line, also appended to the returned string
* @returns {string}
*
* @example /// returns 'a,b\r\n3,4\r\nx,"y\nz"\r\n'
toCSV([
{a: 3, b: 4, c: 5},
{a: 'x', b: 'y\nz'}
], ['a', 'b'])
*/
export function toCSV(dataArray, fieldNames, eol = '\r\n') {
return dataArray.reduce((acc, record) =>
acc + fieldNames.map(field => csvEscape(record[field])).join(',') + eol
, fieldNames.map(csvEscape).join(',') + eol);
}
function csvEscape(text) {
if (! /[\x0a\x0d\x22\x2c]/.test(text)) return text;
text = text.replaceAll('"', '""');
return `"${text}"`;
}
/**
* @func parseCSV
* @desc Parse valid MIME type `text/csv` string to array or object. Empty records are ignored.
* @param {string} csv
* @param {boolean} [hasHeader=true] If truthy, an array of objects with property names assigned by the first record is returned; if falsy, an array of arrays is returned.
* @returns {Array}
*
* @example /// returns [{a: '3', b: '4'}, {a: '7,', b: '9"'}]
parseCSV('"a",b\r\n"3",4\r\n"7,","9"""\r\n')
*
* @example /// returns [['3', '4'], ['7,', '9"']]
parseCSV('"3",4\r\r\n\n"7,","9"""\r\n', false)
*/
export function parseCSV(csv, hasHeader = true) {
let quotes = 0, pos = 0, record = [];
const allRecords = [];
csv += '\n'; // make sure the last record will be pushed
for (let i = 0; i < csv.length; ++i) {
const char = csv.charAt(i);
if (char === '"') ++quotes;
if (quotes % 2) continue;
if (['\n', '\r', ','].includes(char)) {
let value = csv.substring(pos, i);
if (value.startsWith('"')) value = value.slice(1, -1);
value = value.replaceAll('""', '"');
pos = i + 1;
if (char === ',') record.push(value);
else if (record.length) {
record.push(value);
allRecords.push(record);
record = [];
}
}
}
if (! hasHeader) return allRecords;
const fields = allRecords.shift();
return allRecords.map(array =>
fields.reduce((obj, name, index) => Object.assign(obj, {[name]: array[index]}), {})
);
}
/**
* @func base64ToBlob
* @param {string} string - base64 or data URL
* @param {string} [type] - MIME type. Shall be omitted if `string` is a data URL.
* @returns {Blob}
*/
export function base64ToBlob(string, type) {
if (typeof type !== 'string') {
type = string.substring(5, string.indexOf(';'));
string = string.slice(type.length + 13);
}
const decoded = atob(string);
const buffer = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; ++i)
buffer[i] = decoded.charCodeAt(i);
return new Blob([buffer], {type});
}
/**
* @func modifyURLBySearchParams
* @param {string | URL | Location} url
* @param {string | URLSearchParams | FormData} searchParams
* @returns {URL}
* @example /// returns 'https://example.com/?page=3&foo=4'
* modifyURLBySearchParams('https://example.com/?page=2', {page: 3, foo: 4}).toString();
*/
export function modifyURLBySearchParams(url, searchParams) {
if (! (url instanceof URL)) url = new URL(url, document?.baseURI);
if (! (searchParams instanceof URLSearchParams)) searchParams = new URLSearchParams(searchParams);
for (const [key, value] of searchParams.entries())
url.searchParams.set(key, value);
return url;
}
/**
* @func dateFormat
* @desc Simulate `DateTime::format` of PHP.
* @see {@link https://www.php.net/manual/zh/datetime.format.php}
* @param {string} format
* @param {Date | string} date
* @returns {string}
*/
export function dateFormat(format, date = new Date()) {
let d = (date instanceof Date) ? date : new Date(date);
if (isNaN(d.getTime())) {
const match = /(\d{2}):(\d{2})(:(\d{2}))?/.exec(date);
if (! match) {
console.warn('Invalid Date');
return '';
}
d = new Date();
d.setHours(match[1]);
d.setMinutes(match[2]);
if (match[3]) d.setSeconds(match[4]);
}
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'Octoboer', 'November', 'December'];
return format.split('').map(c => {
switch (c) {
case 'd': return zf(d.getDate());
case 'D': return weekdays[d.getDay()].slice(0, 3);
case 'j': return d.getDate();
case 'l': return weekdays[d.getDay()];
case 'N': return (d.getDay() + 6) % 7 + 1;
case 'S': {
switch (d.getDate()) {
case 1: case 21: case 31: return 'st';
case 2: case 22: return 'nd';
case 3: case 23: return 'rd';
default: return 'th';
}
}
case 'w': return d.getDay();
case 'z': return (
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
- Date.UTC(d.getFullYear())
) / 86400000; // https://stackoverflow.com/questions/8619879/#40975730
case 'W': {
const thursday = new Date(
d.getFullYear(),
d.getMonth(),
d.getDate() + 4 - (d.getDay() || 7)
);
const firstDay = new Date(thursday.getFullYear(), 0, 1);
return zf(Math.floor((thursday - firstDay) / 86400000 / 7) + 1);
}
case 'F': return months[d.getMonth()];
case 'm': return zf(d.getMonth() + 1);
case 'M': return months[d.getMonth()].slice(0, 3);
case 'n': return d.getMonth() + 1;
case 't': return (new Date(d.getFullYear(), d.getMonth() + 1, 0)).getDate();
case 'L': {
const leapDay = new Date(d.getFullYear(), 1, 29);
return (leapDay.getDate() === 29) ? 1 : 0;
}
case 'o': {
const thursday = new Date(
d.getFullYear(),
d.getMonth(),
d.getDate() + 4 - (d.getDay() || 7)
);
return zf(thursday.getFullYear(), 4);
}
case 'X': return zf(d.getFullYear(), 4, true);
case 'x': {
const year = d.getFullYear();
return (year >= 10000 ? '+' : '') + zf(year, 4);
}
case 'Y': return zf(d.getFullYear(), 4);
case 'y': {
let y = d.getFullYear() % 100;
if (y < 0) y += 100;
return zf(y);
}
case 'a': return (d.getHours() < 12) ? 'am' : 'pm';
case 'A': return (d.getHours() < 12) ? 'AM' : 'PM';
case 'B': return zf(Math.floor((d.getTime() + 3600000) % 86400000 / 86400), 3);
case 'g': return (d.getHours() + 11) % 12 + 1;
case 'G': return d.getHours();
case 'h': return zf((d.getHours() + 11) % 12 + 1);
case 'H': return zf(d.getHours());
case 'i': return zf(d.getMinutes());
case 's': return zf(d.getSeconds());
case 'u': return zf(d.getMilliseconds(), 3) + '000';
case 'v': return zf(d.getMilliseconds(), 3);
case 'e': return Intl.DateTimeFormat().resolvedOptions().timeZone;
case 'I': {
const stdOffset = Math.max(
new Date(d.getFullYear(), 0).getTimezoneOffset(),
new Date(d.getFullYear(), 6).getTimezoneOffset()
);
return (d.getTimezoneOffset() < stdOffset) ? 1 : 0;
}
case 'O':
case 'P':
case 'p': {
let tzo = d.getTimezoneOffset();
if (tzo === 0 && c === 'p') return 'Z';
const neg = tzo >= 0;
if (! neg) tzo = - tzo;
let result = neg ? '-' : '+';
result += zf(Math.floor(tzo / 60));
if (c !== 'O') result += ':';
result += zf(tzo % 60);
return result;
}
case 'T': {
return new Intl.DateTimeFormat(undefined, { timeZoneName: "short" })
.formatToParts(d)
.find(part => part.type === 'timeZoneName').value;
}
case 'Z': return d.getTimezoneOffset() * -60;
case 'c': return dateFormat('Y-m-d', d) + 'T' + dateFormat('H:i:sP', d);
case 'r': return dateFormat('D, d M Y H:i:s P', d);
case 'U': return Math.round(d.getTime() / 1000);
default: return c;
}
}).join('');
function zf(num, digit = 2, signed = false) {
const neg = num < 0;
if (neg) num = - num;
const posSign = signed ? '+' : '';
return (neg ? '-' : posSign) + num.toString().padStart(digit, '0');
}
}
/**
* @func numberFormat
* @desc A shortcut to call a method of an anonymous `Intl.NumberFormat`.
* @param {Number|BigInt|string} number
* @param {Object} [options={}]
* @returns {string}
*/
export function numberFormat(number, options = {}) {
return new Intl.NumberFormat(options.locales, options).format(number);
}
Object.assign(utilString, {
camelize, kebabize,
parseChineseNumber,
compareVersionNumbers,
toCSV, parseCSV,
base64ToBlob,
modifyURLBySearchParams,
dateFormat,
numberFormat
});
export default utilString;