@calmhive/calmhive-cli
Version:
🐝 Intelligent Claude CLI wrapper with rule injection, AFk background processing, voice interface, and adaptive retry
518 lines (470 loc) • 15.9 kB
JavaScript
/**
* String utility functions for common operations
* Provides safe, tested string manipulation methods
*
* MODERNIZED: Now using lodash and change-case for battle-tested implementations
* Maintains 100% API compatibility with previous version
*/
const _ = require('lodash');
const changeCase = require('change-case');
class StringUtils {
/**
* Capitalizes the first letter of a string
* @param {string} str - The string to capitalize
* @returns {string} String with first letter capitalized
* @throws {Error} If input is not a string
*/
static capitalize(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
if (str === '') {return str;}
// Only capitalize first letter if it's lowercase
if (str[0] === str[0].toLowerCase()) {
return str[0].toUpperCase() + str.slice(1);
}
return str; // Return unchanged if already capitalized or all uppercase
}
/**
* Converts string to camelCase
* @param {string} str - The string to convert
* @returns {string} String in camelCase format
* @throws {Error} If input is not a string
*/
static toCamelCase(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return changeCase.camelCase(str);
}
/**
* Converts string to kebab-case
* @param {string} str - The string to convert
* @returns {string} String in kebab-case format
* @throws {Error} If input is not a string
*/
static toKebabCase(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return changeCase.kebabCase(str);
}
/**
* Converts string to snake_case
* @param {string} str - The string to convert
* @returns {string} String in snake_case format
* @throws {Error} If input is not a string
*/
static toSnakeCase(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return changeCase.snakeCase(str);
}
/**
* Truncates a string to specified length with optional suffix
* @param {string} str - The string to truncate
* @param {number} length - Maximum length
* @param {string} suffix - Suffix to append (default: '...')
* @returns {string} Truncated string
* @throws {Error} If input is not a string or length is not a positive number
*/
static truncate(str, length, suffix = '...') {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
if (!Number.isInteger(length) || length < 0) {
throw new Error('Length must be a non-negative integer');
}
if (typeof suffix !== 'string') {
throw new Error('Suffix must be a string');
}
return _.truncate(str, {
length: length,
omission: suffix
});
}
/**
* Removes leading and trailing whitespace and normalizes internal whitespace
* @param {string} str - The string to clean
* @returns {string} Cleaned string
* @throws {Error} If input is not a string
*/
static clean(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return str.trim().replace(/\s+/g, ' ');
}
/**
* Escapes HTML special characters
* @param {string} str - The string to escape
* @returns {string} HTML-escaped string
* @throws {Error} If input is not a string
*/
static escapeHtml(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
}
/**
* Unescapes HTML entities
* @param {string} str - The string to unescape
* @returns {string} HTML-unescaped string
* @throws {Error} If input is not a string
*/
static unescapeHtml(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return str
.replace(///g, '/')
.replace(/'/g, '\'')
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/&/g, '&');
}
/**
* Generates a slug from a string (URL-friendly version)
* @param {string} str - The string to slugify
* @returns {string} URL-friendly slug
* @throws {Error} If input is not a string
*/
static slugify(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return changeCase.kebabCase(str.replace(/[^\w\s-]/g, ''));
}
/**
* Counts the number of words in a string
* @param {string} str - The string to count words in
* @returns {number} Number of words
* @throws {Error} If input is not a string
*/
static wordCount(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
const cleaned = str.trim();
if (cleaned === '') {
return 0;
}
return cleaned.split(/\s+/).length;
}
/**
* Reverses a string
* @param {string} str - The string to reverse
* @returns {string} Reversed string
* @throws {Error} If input is not a string
*/
static reverse(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return str.split('').reverse().join('');
}
/**
* Checks if a string is a palindrome (ignoring case and spaces)
* @param {string} str - The string to check
* @returns {boolean} True if string is a palindrome
* @throws {Error} If input is not a string
*/
static isPalindrome(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
const cleaned = str.toLowerCase().replace(/[^a-z0-9]/g, '');
return cleaned === cleaned.split('').reverse().join('');
}
/**
* Pads a string to a target length with specified character
* @param {string} str - The string to pad
* @param {number} length - Target length
* @param {string} padChar - Character to pad with (default: ' ')
* @param {string} direction - 'left', 'right', or 'both' (default: 'left')
* @returns {string} Padded string
* @throws {Error} If inputs are invalid
*/
static pad(str, length, padChar = ' ', direction = 'left') {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
if (!Number.isInteger(length) || length < 0) {
throw new Error('Length must be a non-negative integer');
}
if (typeof padChar !== 'string' || padChar.length !== 1) {
throw new Error('Pad character must be a single character string');
}
if (!['left', 'right', 'both'].includes(direction)) {
throw new Error('Direction must be "left", "right", or "both"');
}
switch (direction) {
case 'left':
return _.padStart(str, length, padChar);
case 'right':
return _.padEnd(str, length, padChar);
case 'both':
// lodash doesn't have padBoth, so we implement it
const padding = length - str.length;
if (padding <= 0) {
return str;
}
const leftPadding = Math.floor(padding / 2);
const rightPadding = padding - leftPadding;
return padChar.repeat(leftPadding) + str + padChar.repeat(rightPadding);
default:
return str;
}
}
/**
* Extracts all email addresses from a string
* @param {string} str - The string to extract emails from
* @returns {string[]} Array of email addresses found
* @throws {Error} If input is not a string
*/
static extractEmails(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g;
return str.match(emailRegex) || [];
}
/**
* Formats a duration in milliseconds to human-readable string
* @param {number} milliseconds - Duration in milliseconds
* @param {boolean} short - Use short format (default: false)
* @returns {string} Formatted duration string
* @throws {Error} If input is not a number
*/
static formatDuration(milliseconds, short = false) {
if (typeof milliseconds !== 'number' || milliseconds < 0) {
throw new Error('Input must be a non-negative number');
}
const units = [
{ name: 'day', short: 'd', value: 24 * 60 * 60 * 1000 },
{ name: 'hour', short: 'h', value: 60 * 60 * 1000 },
{ name: 'minute', short: 'm', value: 60 * 1000 },
{ name: 'second', short: 's', value: 1000 },
{ name: 'millisecond', short: 'ms', value: 1 }
];
if (milliseconds === 0) {
return short ? '0ms' : '0 milliseconds';
}
const parts = [];
let remaining = milliseconds;
for (const unit of units) {
const count = Math.floor(remaining / unit.value);
if (count > 0) {
const label = short ? unit.short : count === 1 ? unit.name : `${unit.name}s`;
const separator = short ? '' : ' ';
parts.push(`${count}${separator}${label}`);
remaining -= count * unit.value;
}
if (parts.length >= 2) {break;} // Limit to 2 most significant units
}
return parts.join(short ? ' ' : ', ');
}
/**
* Formats bytes to human-readable string
* @param {number} bytes - Number of bytes
* @param {number} decimals - Number of decimal places (default: 2)
* @returns {string} Formatted bytes string
* @throws {Error} If input is not a number
*/
static formatBytes(bytes, decimals = 2) {
if (typeof bytes !== 'number' || bytes < 0) {
throw new Error('Input must be a non-negative number');
}
if (!Number.isInteger(decimals) || decimals < 0) {
throw new Error('Decimals must be a non-negative integer');
}
if (bytes === 0) {return '0 Bytes';}
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Formats a timestamp to human-readable string
* @param {number|Date} timestamp - Timestamp or Date object
* @param {string} format - Format type: 'full', 'date', 'time', 'relative' (default: 'full')
* @returns {string} Formatted timestamp string
* @throws {Error} If input is invalid
*/
static formatTimestamp(timestamp, format = 'full') {
let date;
if (timestamp instanceof Date) {
date = timestamp;
} else if (typeof timestamp === 'number') {
date = new Date(timestamp);
} else {
throw new Error('Input must be a number or Date object');
}
if (isNaN(date.getTime())) {
throw new Error('Invalid date provided');
}
if (!['full', 'date', 'time', 'relative'].includes(format)) {
throw new Error('Format must be "full", "date", "time", or "relative"');
}
switch (format) {
case 'date':
return date.toLocaleDateString();
case 'time':
return date.toLocaleTimeString();
case 'relative':
return this.formatRelativeTime(date);
case 'full':
default:
return date.toLocaleString();
}
}
/**
* Formats a date to relative time string (e.g., "2 hours ago")
* @param {Date} date - Date object
* @returns {string} Relative time string
*/
static formatRelativeTime(date) {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
if (diffMs < 0) {
return 'in the future';
}
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {return `${days} day${days === 1 ? '' : 's'} ago`;}
if (hours > 0) {return `${hours} hour${hours === 1 ? '' : 's'} ago`;}
if (minutes > 0) {return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;}
if (seconds > 5) {return `${seconds} seconds ago`;}
return 'just now';
}
/**
* Formats a number with thousands separators and decimal places
* @param {number} num - Number to format
* @param {number} decimals - Number of decimal places (default: 0)
* @param {string} locale - Locale for formatting (default: 'en-US')
* @returns {string} Formatted number string
* @throws {Error} If input is not a number
*/
static formatNumber(num, decimals = 0, locale = 'en-US') {
if (typeof num !== 'number') {
throw new Error('Input must be a number');
}
if (!Number.isInteger(decimals) || decimals < 0) {
throw new Error('Decimals must be a non-negative integer');
}
if (typeof locale !== 'string') {
throw new Error('Locale must be a string');
}
return num.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
});
}
/**
* Validates and normalizes a URL
* @param {string} url - URL to validate
* @param {boolean} allowRelative - Allow relative URLs (default: false)
* @returns {string|null} Normalized URL or null if invalid
* @throws {Error} If input is not a string
*/
static normalizeUrl(url, allowRelative = false) {
if (typeof url !== 'string') {
throw new Error('Input must be a string');
}
try {
// Handle relative URLs
if (allowRelative && !url.includes('://')) {
return url.startsWith('/') ? url : `/${url}`;
}
const urlObj = new URL(url);
return urlObj.href;
} catch (error) {
return null;
}
}
/**
* Safely encodes a string to Base64
* @param {string} str - String to encode
* @returns {string} Base64 encoded string
* @throws {Error} If input is not a string
*/
static toBase64(str) {
if (typeof str !== 'string') {
throw new Error('Input must be a string');
}
return Buffer.from(str, 'utf8').toString('base64');
}
/**
* Safely decodes a Base64 string
* @param {string} base64 - Base64 string to decode
* @returns {string|null} Decoded string or null if invalid
* @throws {Error} If input is not a string
*/
static fromBase64(base64) {
if (typeof base64 !== 'string') {
throw new Error('Input must be a string');
}
// Validate Base64 format (only A-Z, a-z, 0-9, +, /, = allowed)
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(base64)) {
return null;
}
try {
const decoded = Buffer.from(base64, 'base64').toString('utf8');
// Verify round-trip to ensure it was valid Base64
const reencoded = Buffer.from(decoded, 'utf8').toString('base64');
if (reencoded !== base64 && base64 !== '') {
return null;
}
return decoded;
} catch (error) {
return null;
}
}
/**
* Safely parses JSON string with error handling
* @param {string} jsonStr - JSON string to parse
* @param {*} defaultValue - Default value if parsing fails
* @returns {*} Parsed object or default value
* @throws {Error} If input is not a string
*/
static safeJsonParse(jsonStr, defaultValue = null) {
if (typeof jsonStr !== 'string') {
throw new Error('Input must be a string');
}
try {
return JSON.parse(jsonStr);
} catch (error) {
return defaultValue;
}
}
/**
* Safely stringifies object to JSON with formatting
* @param {*} obj - Object to stringify
* @param {number} spaces - Number of spaces for indentation (default: 0)
* @returns {string|null} JSON string or null if fails
*/
static safeJsonStringify(obj, spaces = 0) {
if (!Number.isInteger(spaces) || spaces < 0) {
throw new Error('Spaces must be a non-negative integer');
}
try {
return JSON.stringify(obj, null, spaces);
} catch (error) {
return null;
}
}
}
module.exports = StringUtils;