🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
555 lines
16 KiB
JavaScript
Executable File
555 lines
16 KiB
JavaScript
Executable File
/*
|
|
* Core utility functions for the RSpade framework.
|
|
* These functions handle type checking, type conversion, string manipulation,
|
|
* and object/array utilities. They mirror functionality from PHP functions.
|
|
*
|
|
* Other utility functions are organized in:
|
|
* - async.js: Async utilities (sleep, debounce, mutex)
|
|
* - browser.js: Browser/DOM utilities (is_mobile, scroll functions)
|
|
* - datetime.js: Date/time utilities
|
|
* - hash.js: Hashing and comparison
|
|
* - error.js: Error handling
|
|
*/
|
|
|
|
// Todo: test that prod build identifies and removes uncalled functions from the final bundle.
|
|
|
|
// ============================================================================
|
|
// CONSTANTS AND HELPERS
|
|
// ============================================================================
|
|
|
|
// Define commonly used constants
|
|
const undef = 'undefined';
|
|
|
|
/**
|
|
* Iterates over arrays or objects with promise support
|
|
*
|
|
* Works with both synchronous and asynchronous callbacks. If the callback
|
|
* returns promises, they are executed in parallel and this function returns
|
|
* a promise that resolves when all parallel tasks complete.
|
|
*
|
|
* @param {Array|Object} obj - Collection to iterate
|
|
* @param {Function} callback - Function to call for each item (value, key) - can be async
|
|
* @returns {Promise|undefined} Promise if any callbacks return promises, undefined otherwise
|
|
*
|
|
* @example
|
|
* // Synchronous usage
|
|
* foreach([1,2,3], (val) => console.log(val));
|
|
*
|
|
* @example
|
|
* // Asynchronous usage - waits for all to complete
|
|
* await foreach([1,2,3], async (val) => {
|
|
* await fetch('/api/process/' + val);
|
|
* });
|
|
*/
|
|
function foreach(obj, callback) {
|
|
const results = [];
|
|
|
|
if (Array.isArray(obj)) {
|
|
obj.forEach((value, index) => {
|
|
results.push(callback(value, index));
|
|
});
|
|
} else if (obj && typeof obj === 'object') {
|
|
for (let key in obj) {
|
|
if (obj.hasOwnProperty(key)) {
|
|
results.push(callback(obj[key], key));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter for promises
|
|
const promises = results.filter((result) => result && typeof result.then === 'function');
|
|
|
|
// If there are any promises, return Promise.all to wait for all to complete
|
|
if (promises.length > 0) {
|
|
return Promise.all(promises);
|
|
}
|
|
|
|
// No promises returned, so we're done
|
|
return undefined;
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// TYPE CHECKING FUNCTIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Checks if a value is numeric
|
|
* @param {*} n - Value to check
|
|
* @returns {boolean} True if the value is a finite number
|
|
*/
|
|
function is_numeric(n) {
|
|
return !isNaN(parseFloat(n)) && isFinite(n);
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is a string
|
|
* @param {*} s - Value to check
|
|
* @returns {boolean} True if the value is a string
|
|
*/
|
|
function is_string(s) {
|
|
return typeof s == 'string';
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is an integer
|
|
* @param {*} n - Value to check
|
|
* @returns {boolean} True if the value is an integer
|
|
*/
|
|
function is_integer(n) {
|
|
return Number.isInteger(n);
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is a promise-like object
|
|
* @param {*} obj - Value to check
|
|
* @returns {boolean} True if the value has a then method
|
|
*/
|
|
function is_promise(obj) {
|
|
return typeof obj == 'object' && typeof obj.then == 'function';
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is an array
|
|
* @param {*} obj - Value to check
|
|
* @returns {boolean} True if the value is an array
|
|
*/
|
|
function is_array(obj) {
|
|
return Array.isArray(obj);
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is an object (excludes null)
|
|
* @param {*} obj - Value to check
|
|
* @returns {boolean} True if the value is an object and not null
|
|
*/
|
|
function is_object(obj) {
|
|
return typeof obj === 'object' && obj !== null;
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is a function
|
|
* @param {*} function_to_check - Value to check
|
|
* @returns {boolean} True if the value is a function
|
|
*/
|
|
function is_function(function_to_check) {
|
|
return function_to_check && {}.toString.call(function_to_check) === '[object Function]';
|
|
}
|
|
|
|
/**
|
|
* Checks if a string is a valid email address
|
|
* Uses a practical RFC 5322 compliant regex that matches 99.99% of real-world email addresses
|
|
* @param {string} email - Email address to validate
|
|
* @returns {boolean} True if the string is a valid email address
|
|
*/
|
|
function is_email(email) {
|
|
if (!is_string(email)) {
|
|
return false;
|
|
}
|
|
const regex = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
|
|
return regex.test(email);
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is defined (not undefined)
|
|
* @param {*} value - Value to check
|
|
* @returns {boolean} True if value is not undefined
|
|
*/
|
|
function isset(value) {
|
|
return typeof value != undef;
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is empty (null, undefined, 0, "", empty array/object)
|
|
* @param {*} object - Value to check
|
|
* @returns {boolean} True if the value is considered empty
|
|
*/
|
|
function empty(object) {
|
|
if (typeof object == undef) {
|
|
return true;
|
|
}
|
|
if (object === null) {
|
|
return true;
|
|
}
|
|
if (typeof object == 'string' && object == '') {
|
|
return true;
|
|
}
|
|
if (typeof object == 'number') {
|
|
return object == 0;
|
|
}
|
|
if (Array.isArray(object)) {
|
|
return !object.length;
|
|
}
|
|
if (typeof object == 'function') {
|
|
return false;
|
|
}
|
|
for (let key in object) {
|
|
if (object.hasOwnProperty(key)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ============================================================================
|
|
// TYPE CONVERSION FUNCTIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Converts a value to a floating point number
|
|
* Returns 0 for null, undefined, NaN, or non-numeric values
|
|
* @param {*} val - Value to convert
|
|
* @returns {number} Floating point number
|
|
*/
|
|
function float(val) {
|
|
// Handle null, undefined, empty string
|
|
if (val === null || val === undefined || val === '') {
|
|
return 0.0;
|
|
}
|
|
|
|
// Try to parse the value
|
|
const parsed = parseFloat(val);
|
|
|
|
// Check for NaN and return 0 if parsing failed
|
|
return isNaN(parsed) ? 0.0 : parsed;
|
|
}
|
|
|
|
/**
|
|
* Converts a value to an integer
|
|
* Returns 0 for null, undefined, NaN, or non-numeric values
|
|
* @param {*} val - Value to convert
|
|
* @returns {number} Integer value
|
|
*/
|
|
function int(val) {
|
|
// Handle null, undefined, empty string
|
|
if (val === null || val === undefined || val === '') {
|
|
return 0;
|
|
}
|
|
|
|
// Try to parse the value
|
|
const parsed = parseInt(val, 10);
|
|
|
|
// Check for NaN and return 0 if parsing failed
|
|
return isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
|
|
/**
|
|
* Converts a value to a string
|
|
* Returns empty string for null or undefined
|
|
* @param {*} val - Value to convert
|
|
* @returns {string} String representation
|
|
*/
|
|
function str(val) {
|
|
// Handle null and undefined specially
|
|
if (val === null || val === undefined) {
|
|
return '';
|
|
}
|
|
|
|
// Convert to string
|
|
return String(val);
|
|
}
|
|
|
|
/**
|
|
* Converts numeric strings to numbers, returns all other values unchanged
|
|
* Used when you need to ensure numeric types but don't want to force
|
|
* conversion of non-numeric values (which would become 0)
|
|
* @param {*} val - Value to convert
|
|
* @returns {*} Number if input was numeric string, otherwise unchanged
|
|
*/
|
|
function value_unless_numeric_string_then_numeric_value(val) {
|
|
// If it's already a number, return it
|
|
if (typeof val === 'number') {
|
|
return val;
|
|
}
|
|
|
|
// If it's a string and numeric, convert it
|
|
if (is_string(val) && is_numeric(val)) {
|
|
// Use parseFloat to handle both integers and floats
|
|
return parseFloat(val);
|
|
}
|
|
|
|
// Return everything else unchanged (null, objects, non-numeric strings, etc.)
|
|
return val;
|
|
}
|
|
|
|
// ============================================================================
|
|
// STRING MANIPULATION FUNCTIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Escapes HTML special characters (uses Lodash escape)
|
|
* @param {string} str - String to escape
|
|
* @returns {string} HTML-escaped string
|
|
*/
|
|
function html(str) {
|
|
return _.escape(str);
|
|
}
|
|
|
|
/**
|
|
* Sanitizes HTML from WYSIWYG editors to prevent XSS attacks
|
|
*
|
|
* Uses DOMPurify to filter potentially malicious HTML while preserving
|
|
* safe formatting tags. Suitable for user-generated rich text content.
|
|
*
|
|
* @param {string} html_string - HTML string to sanitize
|
|
* @returns {string} Sanitized HTML safe for display
|
|
*/
|
|
function safe_html(html_string) {
|
|
return DOMPurify.sanitize(html_string, {
|
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'a', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'div', 'span'],
|
|
ALLOWED_ATTR: ['href', 'title', 'target', 'src', 'alt', 'width', 'height', 'class'],
|
|
ALLOW_DATA_ATTR: false,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Converts newlines to HTML line breaks
|
|
* @param {string} str - String to convert
|
|
* @returns {string} String with newlines replaced by <br />
|
|
*/
|
|
function nl2br(str) {
|
|
if (typeof str === undef || str === null) {
|
|
return '';
|
|
}
|
|
return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1<br />$2');
|
|
}
|
|
|
|
/**
|
|
* Escapes HTML and converts newlines to <br />
|
|
* @param {string} str - String to process
|
|
* @returns {string} HTML-escaped string with line breaks
|
|
*/
|
|
function htmlbr(str) {
|
|
return nl2br(html(str));
|
|
}
|
|
|
|
/**
|
|
* URL-encodes a string
|
|
* @param {string} str - String to encode
|
|
* @returns {string} URL-encoded string
|
|
*/
|
|
function urlencode(str) {
|
|
return encodeURIComponent(str);
|
|
}
|
|
|
|
/**
|
|
* URL-decodes a string
|
|
* @param {string} str - String to decode
|
|
* @returns {string} URL-decoded string
|
|
*/
|
|
function urldecode(str) {
|
|
return decodeURIComponent(str);
|
|
}
|
|
|
|
/**
|
|
* JSON-encodes a value
|
|
* @param {*} value - Value to encode
|
|
* @returns {string} JSON string
|
|
*/
|
|
function json_encode(value) {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
/**
|
|
* JSON-decodes a string
|
|
* @param {string} str - JSON string to decode
|
|
* @returns {*} Decoded value
|
|
*/
|
|
function json_decode(str) {
|
|
return JSON.parse(str);
|
|
}
|
|
|
|
/**
|
|
* Console debug output with channel filtering
|
|
* Alias for Debugger.console_debug
|
|
* @param {string} channel - Debug channel name
|
|
* @param {...*} values - Values to log
|
|
*/
|
|
function console_debug(channel, ...values) {
|
|
Debugger.console_debug(channel, ...values);
|
|
}
|
|
|
|
/**
|
|
* Replaces all occurrences of a substring in a string
|
|
* @param {string} string - String to search in
|
|
* @param {string} search - Substring to find
|
|
* @param {string} replace - Replacement substring
|
|
* @returns {string} String with all occurrences replaced
|
|
*/
|
|
function replace_all(string, search, replace) {
|
|
if (!is_string(string)) {
|
|
string = string + '';
|
|
}
|
|
return string.split(search).join(replace);
|
|
}
|
|
|
|
/**
|
|
* Capitalizes the first letter of each word
|
|
* @param {string} input - String to capitalize
|
|
* @returns {string} String with first letter of each word capitalized
|
|
*/
|
|
function ucwords(input) {
|
|
return input
|
|
.split(' ')
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
// ============================================================================
|
|
// OBJECT AND ARRAY UTILITIES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Counts the number of properties in an object or elements in an array
|
|
* @param {Object|Array} o - Object or array to count
|
|
* @returns {number} Number of own properties/elements
|
|
*/
|
|
function count(o) {
|
|
let c = 0;
|
|
for (const k in o) {
|
|
if (o.hasOwnProperty(k)) {
|
|
++c;
|
|
}
|
|
}
|
|
return c;
|
|
}
|
|
|
|
/**
|
|
* Creates a shallow clone of an object, array, or function
|
|
* @param {*} obj - Value to clone
|
|
* @returns {*} Cloned value
|
|
*/
|
|
function clone(obj) {
|
|
if (typeof Function.prototype.__clone == undef) {
|
|
Function.prototype.__clone = function () {
|
|
//https://stackoverflow.com/questions/1833588/javascript-clone-a-function
|
|
const that = this;
|
|
let temp = function cloned() {
|
|
return that.apply(this, arguments);
|
|
};
|
|
for (let key in this) {
|
|
if (this.hasOwnProperty(key)) {
|
|
temp[key] = this[key];
|
|
}
|
|
}
|
|
return temp;
|
|
};
|
|
}
|
|
|
|
if (typeof obj == 'function') {
|
|
return obj.__clone();
|
|
} else if (obj.constructor && obj.constructor == Array) {
|
|
return obj.slice(0);
|
|
} else {
|
|
// https://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object/30042948#30042948
|
|
return Object.assign({}, obj);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the first non-null/undefined value from arguments
|
|
* @param {...*} arguments - Values to check
|
|
* @returns {*} First non-null/undefined value, or null if none found
|
|
*/
|
|
function coalesce() {
|
|
let args = Array.from(arguments);
|
|
let return_val = null;
|
|
args.forEach(function (arg) {
|
|
if (return_val === null && typeof arg != undef && arg !== null) {
|
|
return_val = arg;
|
|
}
|
|
});
|
|
return return_val;
|
|
}
|
|
|
|
/**
|
|
* Converts CSV string to array, trimming each element
|
|
* @param {string} str_csv - CSV string to convert
|
|
* @returns {Array<string>} Array of trimmed values
|
|
* @todo Handle quoted/escaped characters
|
|
*/
|
|
function csv_to_array_trim(str_csv) {
|
|
const parts = str_csv.split(',');
|
|
const ret = [];
|
|
foreach(parts, (part) => {
|
|
ret.push(part.trim());
|
|
});
|
|
return ret;
|
|
}
|
|
|
|
// ============================================================================
|
|
// URL UTILITIES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Convert a full URL to short URL by removing protocol
|
|
*
|
|
* Strips http:// or https:// from the beginning of the URL if present.
|
|
* Leaves the URL alone if it doesn't start with either protocol.
|
|
* Removes trailing slash if there is no path.
|
|
*
|
|
* @param {string|null} url - URL to convert
|
|
* @returns {string|null} Short URL without protocol
|
|
*/
|
|
function full_url_to_short_url(url) {
|
|
if (url === null || url === undefined || url === '') {
|
|
return url;
|
|
}
|
|
|
|
// Convert to string if needed
|
|
url = String(url);
|
|
|
|
// Remove http:// or https:// from the beginning (case-insensitive)
|
|
if (url.toLowerCase().indexOf('http://') === 0) {
|
|
url = url.substring(7);
|
|
} else if (url.toLowerCase().indexOf('https://') === 0) {
|
|
url = url.substring(8);
|
|
}
|
|
|
|
// Remove trailing slash if there is no path (just domain)
|
|
// Check if URL is just domain with trailing slash (no path after slash)
|
|
if (url.endsWith('/') && (url.match(/\//g) || []).length === 1) {
|
|
url = url.replace(/\/$/, '');
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Convert a short URL to full URL by adding protocol
|
|
*
|
|
* Adds http:// to the beginning of the URL if it lacks a protocol.
|
|
* Leaves URLs with existing http:// or https:// unchanged.
|
|
* Adds trailing slash if there is no path.
|
|
*
|
|
* @param {string|null} url - URL to convert
|
|
* @returns {string|null} Full URL with protocol
|
|
*/
|
|
function short_url_to_full_url(url) {
|
|
if (url === null || url === undefined || url === '') {
|
|
return url;
|
|
}
|
|
|
|
// Convert to string if needed
|
|
url = String(url);
|
|
|
|
let full_url;
|
|
|
|
// Check if URL already has a protocol (case-insensitive)
|
|
if (url.toLowerCase().indexOf('http://') === 0 || url.toLowerCase().indexOf('https://') === 0) {
|
|
full_url = url;
|
|
} else {
|
|
// Add http:// protocol
|
|
full_url = 'http://' + url;
|
|
}
|
|
|
|
// Add trailing slash if there is no path (just domain)
|
|
// Check if URL has no slash after the domain
|
|
const without_protocol = full_url.replace(/^https?:\/\//i, '');
|
|
if (without_protocol.indexOf('/') === -1) {
|
|
full_url += '/';
|
|
}
|
|
|
|
return full_url;
|
|
}
|