Source: string.mjs

/**
 * @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;