Source

common/tools.js

/**
 * 
 * ## Simple tools shared between client and server
 * 
 */
kiss.addToModule("tools", {
    /**
     * Short ID generator
     * 
     * Use carefully: because of the use of Math.random, the collision risk is high compared to uid().
     * It should be used only on small set of independant objects (for example: field names in a form)
     * 
     * @ignore
     * @param {integer} [size] - Desired size for the uid. Default to 8.
     * @returns {string}
     */
    shortUid(size = 8) {
        size--
        const alphabet = "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ0123456789"
        let id = alphabet[(Math.random() * 52) | 0] // Can't start with a number
        while (size--) id += alphabet[(Math.random() * 62) | 0]
        return id
    },

    /**
     * Shorter non-RFC4122 GUID generator
     * 
     * @ignore
     * @param {number} t - Id length. Defaults to 21 to have a collision risk similar to uid()
     * @returns {string}
     */
    nanoId(t = 21) {
        return crypto.getRandomValues(new Uint8Array(t)).reduce(((t, e) => t += (e &= 63) < 36 ? e.toString(36) : e < 62 ? (e - 26).toString(36).toUpperCase() : e < 63 ? "_" : "-"), "")
    },

    /**
     * Check if a string matches RFC4122 format
     * 
     * @ignore
     * @param {string} str
     * @returns {boolean}
     */
    isUid(str) {
        const RFC4122 = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/;
        if (str.match(RFC4122)) return true
        return false
    },

    /**
     * Check if a model is a pre-defined static model or a custom model generated from the free api.
     * Custom models have their name starting with "plugin" or following the RFC4122 id format.
     * 
     * @ignore
     * @param {string} modelId 
     * @returns {bollean}
     */
    isCustomModel(modelId) {
        if (kiss.tools.isUid(modelId)) return true
        return false
    },

    /**
     * Check if a model has "Audit trail" feature enabled.
     * 
     * @ignore
     * @param {string} modelId 
     * @returns {boolean}
     */
    hasAuditTrail(modelId) {
        const model = kiss.app.models[modelId]
        
        if (!model.features) return false
        if (!model.features["form-feature-audit"]) return false
        if (model.features["form-feature-audit"].active == true) return true
        return false
    },

    /**
     * Check if a model's field is numeric.
     * A field is numeric if:
     * - its type is numeric
     * - it's a lookup or summary field that points to a numeric field
     * 
     * @ignore
     * @param {object} field - Model's field
     * @returns {boolean}
     */
    isNumericField(field) {
        const fieldType = field.type
        return  ["number", "rating", "slider"].includes(fieldType)
        || (fieldType == "lookup" && field.lookup.type == "number")
        || (fieldType == "summary" && field.summary.type == "number")
    },

    /**
     * Check if a variable is a number
     * 
     * @ignore
     * @param {number} n 
     * @returns {boolean} true if the variable is a number
     */
    isNumber(n) {
        return !isNaN(parseFloat(n)) && isFinite(n)
    },

    /**
     * Check if a text is an ISO date
     * 
     * @ignore
     * @param {string} text
     * @param {boolean} [dateOnly] - If true, check only the date part
     * @returns {boolean} true if the variable is an ISO date
     */
    isISODate(text, dateOnly = false) {
        const isoRegex = (dateOnly) ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
        return isoRegex.test(text)
    },

    /**
     * Check if 2 arrays intersect
     * 
     * @ignore
     * @param {array} array1 
     * @param {array} array2 
     * @returns {boolean}
     */
    intersects(array1, array2) {
        const intersection = kiss.tools.intersection(array1, array2)
        return (intersection.length > 0)
    },

    /**
     * Returns the intersection of 2 arrays
     * 
     * @ignore
     * @param {array} array1 
     * @param {array} array2 
     * @returns {array}
     */
    intersection(array1, array2) {
        if (!array1 || !array2) return []
        return array1.filter(item => array2.includes(item))
    },

    /**
     * Calculate the number of days between 2 dates
     * 
     * @ignore
     * @param {date} dateA
     * @param {date} dateB
     */
    daysBetweenDates(dateA, dateB) {
        const timeDifference = Math.abs(dateA.getTime() - dateB.getTime())
        return Math.floor(timeDifference / (3600 * 24 * 1000))
    },

    /**
     * Parse a text and find all the tags within double curly brackets
     * 
     * @ignore
     * @param {string} sourceText - The text to parse
     * @returns {string[]} - Array of tags found in the source text
     * 
     * @example
     * kiss.tools.findTags(" {{A}} * 2 + {{B}} * 3 + {{C}} * 4 ") // ["A", "B", "C"]
     */
    findTags(sourceText) {
        let regex = new RegExp("{{(.*?)}}", "g")
        let text, tags = []
        while (text = regex.exec(sourceText)) tags.push(text[0].substring(2, text[0].length - 2))
        return tags.unique()
    },

    /**
     * Get the current time
     * 
     * @ignore
     * @param {boolean} displaySseconds - true to display the seconds
     * @returns {string} ISO time
     * 
     * @example
     * kiss.tools.getTime() // 15:28
     * kiss.tools.getTime(true) // 15:28:33
     */
    getTime(displaySseconds) {
        const now = new Date()
        const hours = now.getHours().pad(2)
        const minutes = now.getMinutes().pad(2)
        const seconds = now.getSeconds().pad(2)
        return hours + ":" + minutes + ((displaySseconds) ? ":" + seconds : "")
    },

    /**
     * Wrap the setTimout function into a Promise
     * 
     * @ignore
     * @param {integer} [ms] - The number of milliseconds to wait before resolving
     * @returns {function} The promise to execute a function after a given delay
     * 
     * @example
     * kiss.tools.wait(2 * 1000).then(() => console.log("Hello!"))
     */
    wait(ms = 500) {
        return new Promise(resolve => setTimeout(resolve, ms))
    },

    /**
     * Throttle a function so that it can't be executed too many times per second
     * 
     * @ignore
     * @param {number} delay - In milliseconds
     * @param {function} fn - The function to throttle
     * @returns The throttled function
     */
    throttle(delay, fn) {
        let lastCall = 0
        return function (...args) {
            const now = (new Date).getTime()
            if (now - lastCall < delay) return
            lastCall = now
            return fn(...args)
        }
    },

    /**
     * Memoization helper to cache the result of expensive functions that are called multiple times
     * 
     * Return the value if it exists in the cache
     * otherwise, compute the input with the passed in function,
     * update the collection object with the input as the key,
     * and compute result as the value to that key
     * End result will be key-value pairs stored inside cache
     * 
     * @ignore
     * @param (function) func - Function to memoize
     * @returns {*} The result of the passed function, whatever it is
     * 
     * @example
     * kiss.tools.memoize((a) => 2 * a)
     */
    memoize(func) {
        const cache = {}
        return (input) => {
            log("MEMOIZED:" + input)
            if (cache[input]) log("!MEMOIZED: sent cached result!")
            return cache[input] || (cache[input] = func(input))
        }
    },

    /**
     * Compute the distance in km between 2 geolocation points using Haversine formula
     * 
     * @ignore
     * @param {number} lat1 
     * @param {number} lon1 
     * @param {number} lat2 
     * @param {number} lon2 
     * @returns {number} Distance in km
     */
    distanceInKm(lat1, lon1, lat2, lon2) {
        const earthRadius = 6371
        const dLat = kiss.tools.degToRad(lat2 - lat1)
        const dLon = kiss.tools.degToRad(lon2 - lon1)

        const a =
            Math.sin(dLat / 2) * Math.sin(dLat / 2) +
            Math.cos(kiss.tools.degToRad(lat1)) * Math.cos(kiss.tools.degToRad(lat2)) *
            Math.sin(dLon / 2) * Math.sin(dLon / 2)

        const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
        const distance = earthRadius * c
        return distance
    },

    /**
     * Convert degrees to radians
     * 
     * @ignore
     * @param {number} degrees 
     * @returns {number} radians
     */
    degToRad(degrees) {
        return degrees * Math.PI / 180
    },

    /**
     * Check if 2 geolocation points are in a given range of kilometers
     * 
     * @ignore
     * @param {object} geolocationA - Example {latitude: X, longitude: Y}
     * @param {object} geolocationB 
     * @param {number} rangeInKm
     * @returns {boolean} true if the 2 geolocations are in the given range of kilometers
     */
    isInRange(geolocationA, geolocationB, rangeInKm) {
        const distance = kiss.tools.distanceInKm(geolocationA.latitude, geolocationA.longitude, geolocationB.latitude, geolocationB.longitude)
        return distance <= rangeInKm
    },

    /**
     * Valie a field value against validation rules
     * 
     * @ignore
     * @param {object} config 
     * @param {*} value 
     * @returns {boolean}
     */
    validateValue(dataType, config, value) {
        // Skip validation if field is readOnly
        if (config.readOnly) return true

        // Required
        if (config.required && (value == "" || value == undefined)) return false
        if (dataType == "rating" && config.required && value === 0) return false

        // Don't try to validate empty fields if they are not required
        if (value == "") return true

        switch (dataType) {
            case "text":
            case "textarea":
            case "password":
                return kiss.tools.validateText(config, value)

            case "number":
                return kiss.tools.validateNumber(config, value)
        }

        // All validation rules passed
        return true
    },

    /**
     * Validate a number field value against validation rules
     * 
     * @ignore
     * @param {object} config
     * @param {number} [config.min]
     * @param {number} [config.max]
     * @param {number} [config.precision]
     * @param {*} value 
     * @returns {boolean}
     */
    validateNumber(config, value) {
        value = Number(value)
        if (
            (
                config.hasOwnProperty("min") &&
                (config.min !== undefined) &&
                (config.min !== 0) &&
                (value < config.min)
            ) ||
            (
                config.hasOwnProperty("max") &&
                (config.max !== undefined) &&
                (config.max !== 0) &&
                (value > config.max)
            )
        ) {
            return false
        }

        if (config.precision == 0 && !Number.isInteger(value)) {
            return false
        }

        return true
    },

    /**
     * Valite a text field value against validation rules
     * 
     * @ignore
     * @param {object} config 
     * @param {number} [config.minLength]
     * @param {number} [config.maxLength]
     * @param {string} [config.validationType] - alpha|alphanumeric|email|url|ip|regex
     * @param {string} [config.validationRegex] - if validation type = "regex"
     * @param {*} value
     * @returns {boolean}
     */
    validateText(config, value) {
        // Text length
        if (kiss.tools.validateTextLength(config, value) == false) return false

        // Regex
        let regex
        switch (config.validationType) {
            case "alpha":
                if (!value.match(kiss.tools.regex.alpha)) return false
                break
            case "alphanumeric":
                if (!value.match(kiss.tools.regex.alphanumeric)) return false
                break
            case "email":
                if (!value.match(kiss.tools.regex.email)) return false
                break
            case "url":
                if (!value.match(kiss.tools.regex.url)) return false
                break
            case "ip":
                if (!value.match(kiss.tools.regex.ip)) return false
                break
            case "regex":
                console.log("The field has to pass the regex: " + regex)
                if (!config.validationRegex) return true
                if (!value.match(config.validationRegex)) return false
        }

        // Excludes HTML
        if (value.containsHTML()) return false

        // All validation rules passed
        return true
    },

    /**
     * Convert HTML to plain text
     * 
     * @param {string} html 
     * @returns {string} The plain text
     */
    convertHtmlToPlainText(html) {
        html = html.replace(/<br\s*\/?>/gi, '\n')
        html = html.replace(/<\/p>/gi, '\n')
        
        var tempDiv = document.createElement("div")
        tempDiv.innerHTML = html
        return tempDiv.textContent || tempDiv.innerText || ""
    },

    /**
     * Valite text field length against validation rules
     * 
     * @ignore
     * @param {object} config 
     * @param {number} [config.minLength]
     * @param {number} [config.maxLength]
     * @param {*} value
     * @returns {boolean}
     */
    validateTextLength(config, value) {
        if (
            (
                config.hasOwnProperty("minLength") &&
                (value.length < config.minLength)
            ) ||
            (
                config.hasOwnProperty("maxLength") &&
                (config.maxLength > 0) &&
                (value.length > config.maxLength)
            )
        ) {
            return false
        }

        return true
    },

    /**
     * Define regex for common fields validation
     * 
     * @ignore
     */
    regex: {
        alpha: new RegExp(`^[a-zA-Z_]+$`),
        alphanumeric: new RegExp(`^[a-zA-Z0-9]([a-zA-Z0-9_])+$`),
        email: new RegExp(`^\\s*([\\w.%+-]+@[\\w.-]+\\.[a-zA-Z]{2,})(\\s*,\\s*[\\w.%+-]+@[\\w.-]+\\.[a-zA-Z]{2,})*\\s*$`),
        url: new RegExp(`^(http(s?):\\/)?\\/(.)+$`),
        ip: new RegExp(`^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$`)
    },

    /**
     * Generates a slug from a plain title
     * 
     * @ignore
     * @param {string} text 
     * @returns {string} The generated slug
     * 
     * @example
     * kiss.tools.generateSlug("My article about dogs") // Returns "my-article-about-dogs"
     */
    generateSlug(text) {
        return text.toString().toLowerCase()
            .normalize("NFD")
            .replace(/[\u0300-\u036f]/g, "")
            .replace(/\s+/g, "-")
            .replace(/[^a-z0-9\-]/g, "")
            .replace(/\-\-+/g, "-")
            .replace(/^-+/, "")
            .replace(/-+$/, "");
    },

    /**
     * Generates an SEO friendly breadcrumb from a list of links
     * 
     * @ignore
     * @param {object[]} items - Each breadcrumb item should be a object like: {label: "Home", url: "https://domain/home"}
     */
    generateBreadcrumb(items) {
        let breadcrumb = '<nav aria-label="breadcrumb"><div itemscope="itemscope" itemtype="http://schema.org/BreadcrumbList" class="breadcrumb"><div class="container">'
        for (let i = 0; i < items.length; i++) {
            let item = items[i]
            let li = `<span itemprop="itemListElement" itemscope="itemscope" itemtype="http://schema.org/ListItem" class="breadcrumb-item${i === items.length - 1 ? ' active' : ''}">`
            if (item.url) {
                li += `<a href="${item.url}" itemprop="item"><span itemprop="name">${item.label}</span> <meta itemprop="position" content="${i + 1}"></a>`
            } else {
                li += `<span itemprop="name">${item.label}</span> <meta itemprop="position" content="${i + 1}">`
            }
            li += '</span>'
            breadcrumb += li
        }
        breadcrumb += '</div></div></nav>'
    }
})


;