diff --git a/app/RSpade/Core/Js/functions.js b/app/RSpade/Core/Js/functions.js index f98724652..bce7a5a35 100755 --- a/app/RSpade/Core/Js/functions.js +++ b/app/RSpade/Core/Js/functions.js @@ -395,6 +395,154 @@ function ucwords(input) { .join(' '); } +/** + * Make a string safe for use as a variable/function name + * @param {string} string - Input string + * @param {number} [max_length=64] - Maximum length + * @returns {string} Safe string + */ +function safe_string(string, max_length = 64) { + // Replace non-alphanumeric with underscores + string = String(string).replace(/[^a-zA-Z0-9_]+/g, '_'); + + // Ensure first character is not a number + if (string === '' || /^[0-9]/.test(string)) { + string = '_' + string; + } + + // Trim to max length + return string.substring(0, max_length); +} + +/** + * Convert snake_case to camelCase + * @param {string} string - Snake case string + * @param {boolean} [capitalize_first=false] - Whether to capitalize first letter (PascalCase) + * @returns {string} Camel case string + */ +function snake_to_camel(string, capitalize_first = false) { + let result = String(string).replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()); + + if (capitalize_first && result.length > 0) { + result = result.charAt(0).toUpperCase() + result.slice(1); + } else if (!capitalize_first && result.length > 0) { + result = result.charAt(0).toLowerCase() + result.slice(1); + } + + return result; +} + +/** + * Convert camelCase to snake_case + * @param {string} string - Camel case string + * @returns {string} Snake case string + */ +function camel_to_snake(string) { + return String(string) + .replace(/^[A-Z]/, (letter) => letter.toLowerCase()) + .replace(/[A-Z]/g, (letter) => '_' + letter.toLowerCase()); +} + +/** + * Common TLDs for domain detection in linkify functions + * @type {string} + */ +const LINKIFY_TLDS = 'com|org|net|edu|gov|io|co|me|info|biz|us|uk|ca|au|de|fr|es|it|nl|ru|jp|cn|in|br|mx|app|dev|xyz|online|site|tech|store|blog|shop'; + +/** + * Convert plain text to HTML with URLs converted to hyperlinks + * + * First escapes the text to HTML, then converts URLs (with protocols) and + * domain-like text (with known TLDs) into clickable hyperlinks. + * + * @param {string|null} content - Plain text content + * @param {boolean} [new_window=true] - Whether to add target="_blank" to links + * @returns {string} HTML with clickable links + */ +function linkify_text(content, new_window = true) { + if (content == null || content === '') { + return ''; + } + + // First escape HTML + const escaped = html(String(content)); + + return _linkify_content(escaped, new_window); +} + +/** + * Convert URLs in HTML to hyperlinks, preserving existing links + * + * Converts URLs (with protocols) and domain-like text (with known TLDs) + * into clickable hyperlinks, but only for text not already inside tags. + * + * @param {string|null} content - HTML content + * @param {boolean} [new_window=true] - Whether to add target="_blank" to links + * @returns {string} HTML with clickable links + */ +function linkify_html(content, new_window = true) { + if (content == null || content === '') { + return ''; + } + + // Split content into segments: inside tags and outside + const pattern = /(]*>.*?<\/a>)/gi; + const segments = String(content).split(pattern); + + let result = ''; + for (const segment of segments) { + // Check if this segment is an tag + if (/^\[\]()]+)/gi; + + // Pattern for domain-like text + const domain_pattern = new RegExp( + '\\b((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+(' + LINKIFY_TLDS + ')(?:\\/[^\\s<>\\[\\]()]*)?)\\b', + 'gi' + ); + + // First, replace URLs with protocol + content = content.replace(url_pattern, (match) => { + // Clean trailing punctuation that's likely not part of URL + const url = match.replace(/[.,;:!?)'\"]+$/, ''); + const trailing = match.slice(url.length); + return '' + url + '' + trailing; + }); + + // Then, replace domain-like text (but not if already inside an href) + content = content.replace(domain_pattern, (match) => { + // Clean trailing punctuation + const domain = match.replace(/[.,;:!?)'\"]+$/, ''); + const trailing = match.slice(domain.length); + return '' + domain + '' + trailing; + }); + + return content; +} + // ============================================================================ // OBJECT AND ARRAY UTILITIES // ============================================================================ diff --git a/app/RSpade/helpers.php b/app/RSpade/helpers.php index 81a8c5b3c..8a699e9c3 100644 --- a/app/RSpade/helpers.php +++ b/app/RSpade/helpers.php @@ -1551,3 +1551,122 @@ function validate_short_url(?string $url): bool return true; } + +/** + * Escape HTML and convert newlines to
+ * + * Combines htmlspecialchars() and nl2br() for displaying user-generated + * plain text as HTML with preserved line breaks. + * + * @param string|null $str String to process + * @return string HTML-escaped string with line breaks + */ +function htmlbr(?string $str): string +{ + if ($str === null || $str === '') { + return ''; + } + + return nl2br(htmlspecialchars($str, ENT_QUOTES | ENT_HTML5, 'UTF-8')); +} + +/** + * Common TLDs for domain detection in linkify functions + */ +define('LINKIFY_TLDS', 'com|org|net|edu|gov|io|co|me|info|biz|us|uk|ca|au|de|fr|es|it|nl|ru|jp|cn|in|br|mx|app|dev|xyz|online|site|tech|store|blog|shop'); + +/** + * Convert plain text to HTML with URLs converted to hyperlinks + * + * First escapes the text to HTML, then converts URLs (with protocols) and + * domain-like text (with known TLDs) into clickable hyperlinks. + * + * @param string|null $content Plain text content + * @param bool $new_window Whether to add target="_blank" to links + * @return string HTML with clickable links + */ +function linkify_text(?string $content, bool $new_window = true): string +{ + if ($content === null || $content === '') { + return ''; + } + + // First escape HTML + $html = htmlspecialchars($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return _linkify_content($html, $new_window); +} + +/** + * Convert URLs in HTML to hyperlinks, preserving existing links + * + * Converts URLs (with protocols) and domain-like text (with known TLDs) + * into clickable hyperlinks, but only for text not already inside tags. + * + * @param string|null $content HTML content + * @param bool $new_window Whether to add target="_blank" to links + * @return string HTML with clickable links + */ +function linkify_html(?string $content, bool $new_window = true): string +{ + if ($content === null || $content === '') { + return ''; + } + + // Split content into segments: inside tags and outside + // Pattern matches ... including nested content + $pattern = '/(]*>.*?<\/a>)/is'; + $segments = preg_split($pattern, $content, -1, PREG_SPLIT_DELIM_CAPTURE); + + $result = ''; + foreach ($segments as $segment) { + // Check if this segment is an tag (starts with ) + if (preg_match('/^ tags to linkify) + * @param bool $new_window Whether to add target="_blank" + * @return string Content with URLs converted to links + */ +function _linkify_content(string $content, bool $new_window): string +{ + $target = $new_window ? ' target="_blank" rel="noopener noreferrer"' : ''; + $tlds = LINKIFY_TLDS; + + // Pattern for URLs with protocol + $url_pattern = '/(https?:\/\/[^\s<>\[\]()]+)/i'; + + // Pattern for domain-like text (domain.tld or subdomain.domain.tld with optional path) + $domain_pattern = '/\b((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+(' . $tlds . ')(?:\/[^\s<>\[\]()]*)?)\b/i'; + + // First, replace URLs with protocol + $content = preg_replace_callback($url_pattern, function ($matches) use ($target) { + $url = $matches[1]; + // Clean trailing punctuation that's likely not part of URL + $url = rtrim($url, '.,;:!?)\'\"'); + return '' . $url . ''; + }, $content); + + // Then, replace domain-like text (but not if already inside an href) + $content = preg_replace_callback($domain_pattern, function ($matches) use ($target) { + $domain = $matches[1]; + // Clean trailing punctuation + $domain = rtrim($domain, '.,;:!?)\'\"'); + // Don't linkify if it looks like it's already in an href attribute + return '' . $domain . ''; + }, $content); + + return $content; +}