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;
+}