/*
* 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);
}
/**
* Converts newlines to HTML line breaks
* @param {string} str - String to convert
* @returns {string} String with newlines replaced by
*/
function nl2br(str) {
if (typeof str === undef || str === null) {
return '';
}
return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2');
}
/**
* Escapes HTML and converts newlines to
* @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} 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;
}