Implement JQHTML function cache ID system and fix bundle compilation Implement underscore prefix for system tables Fix JS syntax linter to support decorators and grant exception to Task system SPA: Update planning docs and wishlists with remaining features SPA: Document Navigation API abandonment and future enhancements Implement SPA browser integration with History API (Phase 1) Convert contacts view page to SPA action Convert clients pages to SPA actions and document conversion procedure SPA: Merge GET parameters and update documentation Implement SPA route URL generation in JavaScript and PHP Implement SPA bootstrap controller architecture Add SPA routing manual page (rsx:man spa) Add SPA routing documentation to CLAUDE.md Phase 4 Complete: Client-side SPA routing implementation Update get_routes() consumers for unified route structure Complete SPA Phase 3: PHP-side route type detection and is_spa flag Restore unified routes structure and Manifest_Query class Refactor route indexing and add SPA infrastructure Phase 3 Complete: SPA route registration in manifest Implement SPA Phase 2: Extract router code and test decorators Rename Jqhtml_Component to Component and complete SPA foundation setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1494 lines
43 KiB
PHP
Executable File
1494 lines
43 KiB
PHP
Executable File
<?php
|
|
|
|
/**
|
|
* RSpade Global Helper Functions
|
|
*
|
|
* A collection of utility functions for the RSpade framework and applications.
|
|
* These provide quality of life improvements for common development tasks.
|
|
*
|
|
* This file is automatically loaded via composer.json autoload files.
|
|
*/
|
|
|
|
/**
|
|
* Check if code is running in IDE context (code analysis/completion)
|
|
*
|
|
* @return bool True if running in IDE, false otherwise
|
|
*/
|
|
function is_ide(): bool
|
|
{
|
|
// Only consider IDE if running in CLI mode
|
|
if (PHP_SAPI !== 'cli') {
|
|
return false;
|
|
}
|
|
|
|
// Check for known IDE environment variables
|
|
$ide_env_vars = [
|
|
'PHPSTORM_IDE', // PhpStorm
|
|
'VSCODE_PID', // VS Code
|
|
'IDEA_INITIAL_DIRECTORY', // IntelliJ IDEA
|
|
'SUBLIME_TEXT', // Sublime Text
|
|
'ATOM_HOME', // Atom
|
|
'NVIM_LISTEN_ADDRESS', // Neovim
|
|
'VIM', // Vim
|
|
'EMACS', // Emacs
|
|
];
|
|
|
|
foreach ($ide_env_vars as $var) {
|
|
if (getenv($var) !== false) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check if running from "Command line code" (common in IDE analysis)
|
|
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20);
|
|
foreach ($trace as $frame) {
|
|
if (isset($frame['file']) && strpos($frame['file'], 'Command line code') !== false) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Debug output helper for development debugging with journal categorization
|
|
*
|
|
* Outputs debug messages with file:line information for tracking code execution.
|
|
* Messages are prefixed with [JOURNAL] category for filtering/categorization.
|
|
* Behavior depends on execution context:
|
|
*
|
|
* CLI Mode:
|
|
* - Outputs to stderr with cyan color formatting
|
|
* - Controlled by SHOW_CONSOLE_DEBUG_CLI environment flag (default: false)
|
|
* - Example: [app/Http/Controllers/TestController.php:42] [DISPATCH] Processing route /login
|
|
*
|
|
* HTTP Mode:
|
|
* - Batches messages and outputs as JavaScript console.log() statements
|
|
* - Messages appear in browser developer console
|
|
* - Controlled by SHOW_CONSOLE_DEBUG_HTTP environment flag (default: true)
|
|
* - Automatically outputs after bundles and on shutdown (even after fatal errors)
|
|
*
|
|
* Environment Flags (set in .env):
|
|
* - SHOW_CONSOLE_DEBUG_CLI=true|false - Enable/disable CLI output (default: false)
|
|
* - SHOW_CONSOLE_DEBUG_HTTP=true|false - Enable/disable HTTP browser console output (default: true)
|
|
*
|
|
* Never outputs in production mode regardless of flags.
|
|
*
|
|
* @param string $channel Channel category (e.g., "DISPATCH", "AUTH", "DB", "CACHE", "BUNDLE")
|
|
* @param mixed ...$values Values to output - strings are printed directly, other types are var_exported
|
|
* @return void
|
|
*/
|
|
function console_debug(string $channel, ...$values)
|
|
{
|
|
\App\RSpade\Core\Debug\Debugger::console_debug($channel, ...$values);
|
|
}
|
|
|
|
/**
|
|
* Forced console debug output - Always visible in development
|
|
*
|
|
* Same as console_debug() but ALWAYS outputs to both web and CLI,
|
|
* bypassing the console_debug enabled/disabled config and filters.
|
|
* Only works in non-production mode - silently ignored in production.
|
|
*
|
|
* Use for critical development notices that developers must see,
|
|
* such as "manifest does not exist, performing full scan".
|
|
*
|
|
* @param string $channel Channel category
|
|
* @param mixed ...$values Values to output
|
|
* @return void
|
|
*/
|
|
function console_debug_force(string $channel, ...$values)
|
|
{
|
|
\App\RSpade\Core\Debug\Debugger::console_debug_force($channel, ...$values);
|
|
}
|
|
|
|
/**
|
|
* Sanity check failure handler
|
|
*
|
|
* This function should be called when a sanity check fails - i.e., when the code
|
|
* encounters a condition that "shouldn't happen" if everything is working correctly.
|
|
* It throws a fatal exception with clear context about where the failure occurred.
|
|
*
|
|
* Use this instead of silently returning or continuing when encountering unexpected conditions.
|
|
*
|
|
* Examples:
|
|
* - After loading files, if a class doesn't exist
|
|
* - When a required file is missing
|
|
* - When a database operation returns unexpected null
|
|
* - When array keys that should exist are missing
|
|
*
|
|
* @param string|null $message Optional specific message about what shouldn't have happened
|
|
* @throws \RuntimeException Always throws with location and context information
|
|
*/
|
|
function shouldnt_happen(?string $message = null): void
|
|
{
|
|
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
|
|
$caller = $backtrace[0] ?? [];
|
|
|
|
$file = $caller['file'] ?? 'unknown';
|
|
$line = $caller['line'] ?? 'unknown';
|
|
|
|
// Make path relative to project root for cleaner output
|
|
if (str_starts_with($file, base_path())) {
|
|
$file = str_replace(base_path() . '/', '', $file);
|
|
}
|
|
|
|
$error_message = "Fatal: shouldnt_happen() was called at {$file}:{$line}\n";
|
|
$error_message .= "This indicates a sanity check failed - the code is not behaving as expected.\n";
|
|
|
|
if ($message) {
|
|
$error_message .= "Details: {$message}\n";
|
|
}
|
|
|
|
$error_message .= 'Please thoroughly review the related code to determine why this error occurred.';
|
|
|
|
throw new \RuntimeException($error_message);
|
|
}
|
|
|
|
/**
|
|
* Recursively delete a directory and all its contents
|
|
*
|
|
* @param string $dir Directory path to delete
|
|
* @param bool $delete_self Whether to delete the directory itself (default: true)
|
|
* @param array $ignore_dirs Directories to skip
|
|
* @return bool Success status
|
|
*/
|
|
function rmdir_recursive($dir, $delete_self = true, $ignore_dirs = [])
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return false;
|
|
}
|
|
|
|
$items = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST,
|
|
);
|
|
|
|
foreach ($items as $item) {
|
|
$path = $item->getPathname();
|
|
|
|
// Skip ignored directories
|
|
if (in_array($path, $ignore_dirs)) {
|
|
continue;
|
|
}
|
|
|
|
if ($item->isFile() || $item->isLink()) {
|
|
unlink($path);
|
|
} elseif ($item->isDir() && !in_array($path, $ignore_dirs)) {
|
|
@rmdir($path);
|
|
}
|
|
}
|
|
|
|
if ($delete_self) {
|
|
return @rmdir($dir);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Extract a list of values from a collection of arrays/objects
|
|
*
|
|
* @param array|object $array Collection to pluck from
|
|
* @param string|null $key Key to extract (null for first element)
|
|
* @return array Array of plucked values
|
|
*/
|
|
function array_pluck($array, $key = null)
|
|
{
|
|
$result = [];
|
|
|
|
if (is_object($array)) {
|
|
$array = (array) $array;
|
|
}
|
|
|
|
foreach ($array as $index => $item) {
|
|
if (is_object($item)) {
|
|
$item = (array) $item;
|
|
}
|
|
|
|
if ($key !== null) {
|
|
if (isset($item[$key])) {
|
|
$result[$index] = $item[$key];
|
|
}
|
|
} else {
|
|
// Get first element
|
|
$result[$index] = reset($item);
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Get only specified keys from an array
|
|
*
|
|
* @param array $array Source array
|
|
* @param array $keys Keys to keep
|
|
* @return array Filtered array
|
|
*/
|
|
function array_only($array, $keys)
|
|
{
|
|
return array_intersect_key($array, array_flip($keys));
|
|
}
|
|
|
|
/**
|
|
* Get all except specified keys from an array
|
|
*
|
|
* @param array $array Source array
|
|
* @param array $keys Keys to exclude
|
|
* @return array Filtered array
|
|
*/
|
|
function array_except($array, $keys)
|
|
{
|
|
return array_diff_key($array, array_flip($keys));
|
|
}
|
|
|
|
/**
|
|
* Recursively merge arrays, combining array values instead of replacing
|
|
*
|
|
* Ported from array_intersperse() in RSpade v3.
|
|
*
|
|
* This function performs a deep merge of two arrays. Unlike array_merge_recursive() which
|
|
* creates sub-arrays for duplicate keys, this function intelligently merges values:
|
|
* - For numeric keys: appends unique values only (no duplicates)
|
|
* - For string keys with array values: recursively merges the arrays
|
|
* - For string keys with scalar values: replaces the target value with source value
|
|
*
|
|
* Accepts arrays by reference for efficiency but creates and returns a new merged array.
|
|
* Does not modify the input arrays.
|
|
*
|
|
* @param array &$target Base array (not modified)
|
|
* @param array &$source Array to merge (not modified)
|
|
* @return array The merged result array
|
|
*
|
|
* @example
|
|
* $target = [
|
|
* 'config' => ['debug' => true, 'env' => 'local'],
|
|
* 'modules' => ['jquery', 'bootstrap'],
|
|
* 'version' => '1.0'
|
|
* ];
|
|
* $source = [
|
|
* 'config' => ['api_url' => '/api', 'env' => 'production'],
|
|
* 'modules' => ['jquery', 'vue'], // jquery won't be duplicated
|
|
* 'version' => '2.0'
|
|
* ];
|
|
* $result = array_merge_deep($target, $source);
|
|
* // Result in $result:
|
|
* // [
|
|
* // 'config' => ['debug' => true, 'env' => 'production', 'api_url' => '/api'],
|
|
* // 'modules' => ['jquery', 'bootstrap', 'vue'],
|
|
* // 'version' => '2.0'
|
|
* // ]
|
|
*/
|
|
function array_merge_deep(array &$target, array &$source): array
|
|
{
|
|
$result = $target;
|
|
|
|
foreach ($source as $key => $value) {
|
|
if (is_numeric($key)) {
|
|
// Numeric key - append if not already present
|
|
if (!in_array($value, $result, true)) {
|
|
$result[] = $value;
|
|
}
|
|
} elseif (is_array($value)) {
|
|
if (!isset($result[$key]) || !is_array($result[$key])) {
|
|
$result[$key] = $value;
|
|
} else {
|
|
$result[$key] = array_merge_deep($result[$key], $value);
|
|
}
|
|
} else {
|
|
$result[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Check if an array has string keys (is associative)
|
|
*
|
|
* @param array $array Array to check
|
|
* @return bool True if has string keys
|
|
*/
|
|
function is_associative_array($array)
|
|
{
|
|
if (!is_array($array) || empty($array)) {
|
|
return false;
|
|
}
|
|
|
|
return count(array_filter(array_keys($array), 'is_string')) > 0;
|
|
}
|
|
|
|
/**
|
|
* Recursively convert objects to arrays
|
|
*
|
|
* @param mixed $object Object or value to convert
|
|
* @return mixed Converted value
|
|
*/
|
|
function object_to_array_recursive($object)
|
|
{
|
|
if (is_object($object)) {
|
|
// Check for to_array method
|
|
if (method_exists($object, 'toArray')) {
|
|
$object = $object->toArray();
|
|
} elseif (method_exists($object, 'to_array')) {
|
|
$object = $object->to_array();
|
|
} else {
|
|
$object = (array) $object;
|
|
}
|
|
}
|
|
|
|
if (is_array($object)) {
|
|
foreach ($object as $key => $value) {
|
|
$object[$key] = object_to_array_recursive($value);
|
|
}
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
/**
|
|
* Generate a cryptographically secure random hash
|
|
*
|
|
* @param int $bytes Number of random bytes (default: 32)
|
|
* @return string Hexadecimal hash
|
|
*/
|
|
function random_hash($bytes = 32)
|
|
{
|
|
return bin2hex(random_bytes($bytes));
|
|
}
|
|
|
|
/**
|
|
* Make a string safe for use as a variable/function name
|
|
*
|
|
* @param string $string Input string
|
|
* @param int $max_length Maximum length (default: 64)
|
|
* @return string Safe string
|
|
*/
|
|
function safe_string($string, $max_length = 64)
|
|
{
|
|
// Replace non-alphanumeric with underscores
|
|
$string = preg_replace('/[^a-zA-Z0-9_]+/', '_', $string);
|
|
|
|
// Ensure first character is not a number
|
|
if (empty($string) || is_numeric($string[0])) {
|
|
$string = '_' . $string;
|
|
}
|
|
|
|
// Trim to max length
|
|
return substr($string, 0, $max_length);
|
|
}
|
|
|
|
/**
|
|
* Get file extension handling double extensions (e.g., .blade.php)
|
|
*
|
|
* @param string $path File path
|
|
* @param bool $double Check for double extensions
|
|
* @return string Extension
|
|
*/
|
|
function file_extension($path, $double = true)
|
|
{
|
|
$filename = basename($path);
|
|
|
|
if ($double) {
|
|
// Check for known double extensions
|
|
$double_extensions = ['blade.php', 'spec.js', 'test.js', 'min.js', 'min.css', 'd.ts'];
|
|
|
|
foreach ($double_extensions as $ext) {
|
|
if (str_ends_with($filename, '.' . $ext)) {
|
|
return $ext;
|
|
}
|
|
}
|
|
}
|
|
|
|
return pathinfo($filename, PATHINFO_EXTENSION);
|
|
}
|
|
|
|
/**
|
|
* Get a view instance by RSX ID (path-agnostic identifier).
|
|
* This allows views to be referenced by their ID instead of file path.
|
|
*
|
|
* @param string $id The ID defined in the view file with @rsx_id directive
|
|
* @param array $data Data to pass to the view
|
|
* @param array $merge_data Data to merge with the view data
|
|
* @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory
|
|
* @throws \Exception if the view is not found
|
|
*/
|
|
function rsx_view($id, $data = [], $merge_data = [])
|
|
{
|
|
// Use manifest to find the view file by ID
|
|
$file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id);
|
|
|
|
if (!$file_path) {
|
|
throw new \InvalidArgumentException("RSX view not found: {$id}");
|
|
}
|
|
|
|
// Share the ID for use in layouts (for body class)
|
|
\Illuminate\Support\Facades\View::share('rsx_view_id', $id);
|
|
|
|
// Share the current view path for bundle validation
|
|
\Illuminate\Support\Facades\View::share('rsx_current_view_path', $file_path);
|
|
|
|
// In development mode, validate the view and layout chain
|
|
if (!app()->environment('production')) {
|
|
// Validate the entire layout chain and bundle placement
|
|
\App\RSpade\Core\Validation\LayoutChainValidator::validate_layout_chain($id);
|
|
|
|
// Get the metadata for the view
|
|
$metadata = \App\RSpade\Core\Manifest\Manifest::get_file($file_path);
|
|
|
|
// Validate the view doesn't have inline styles/scripts
|
|
\App\RSpade\Core\Validation\ViewValidator::validate_view($id, $file_path, $metadata ?? []);
|
|
|
|
// If the view extends a layout, validate each layout in the chain has body class
|
|
if (isset($metadata['rsx_extends'])) {
|
|
$current_extends = $metadata['rsx_extends'];
|
|
$visited = []; // Prevent infinite loops
|
|
|
|
while ($current_extends) {
|
|
if (in_array($current_extends, $visited)) {
|
|
break; // Circular dependency, let LayoutChainValidator handle it
|
|
}
|
|
$visited[] = $current_extends;
|
|
|
|
$layout_path = \App\RSpade\Core\Manifest\Manifest::find_view($current_extends);
|
|
if ($layout_path) {
|
|
// Validate this layout
|
|
\App\RSpade\Core\Validation\ViewValidator::validate_layout($layout_path);
|
|
|
|
// Get metadata to find next parent
|
|
$layout_metadata = \App\RSpade\Core\Manifest\Manifest::get_file($layout_path);
|
|
$current_extends = $layout_metadata['rsx_extends'] ?? null;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to Laravel view name format
|
|
// Remove .blade.php extension
|
|
$view_name = preg_replace('/\.blade\.php$/', '', $file_path);
|
|
|
|
// Handle different view locations
|
|
if (str_starts_with($view_name, 'resources/views/')) {
|
|
$view_name = substr($view_name, strlen('resources/views/'));
|
|
} elseif (str_starts_with($view_name, 'app/RSpade/')) {
|
|
// For framework views, use namespace format
|
|
// The namespace 'rspade' is registered in Rsx_Framework_Provider
|
|
$view_name = 'rspade::' . substr($view_name, strlen('app/RSpade/'));
|
|
} elseif (str_starts_with($view_name, 'rsx/')) {
|
|
// For RSX views, use namespace format
|
|
// The namespace 'rsx' is registered in RsxServiceProvider
|
|
$view_name = str_replace('rsx/', 'rsx::', $view_name);
|
|
}
|
|
|
|
// Convert path separators to dots for Laravel
|
|
$view_name = str_replace('/', '.', $view_name);
|
|
|
|
return view($view_name, $data, $merge_data);
|
|
}
|
|
|
|
/**
|
|
* Get the Laravel view path for a view by RSX ID.
|
|
* Used internally by RSX Blade directives to resolve view paths.
|
|
*
|
|
* @param string $id The ID to look up
|
|
* @return string|null The Laravel view path in dot notation or null if not found
|
|
*/
|
|
function rsx_view_path($id)
|
|
{
|
|
// Remove quotes if passed from Blade directive
|
|
$id = trim($id, "'\"");
|
|
|
|
// Use manifest to find the view file
|
|
$file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id);
|
|
|
|
if (!$file_path) {
|
|
return null;
|
|
}
|
|
|
|
// Convert to Laravel view path format
|
|
// Remove .blade.php extension
|
|
$view_path = preg_replace('/\.blade\.php$/', '', $file_path);
|
|
|
|
// Handle different view locations
|
|
if (str_starts_with($view_path, 'resources/views/')) {
|
|
$view_path = substr($view_path, strlen('resources/views/'));
|
|
} elseif (str_starts_with($view_path, 'rsx/')) {
|
|
// For RSX views, convert to namespace format
|
|
$view_path = str_replace('rsx/', 'rsx::', $view_path);
|
|
}
|
|
|
|
// Convert path separators to dots
|
|
return str_replace('/', '.', $view_path);
|
|
}
|
|
|
|
/**
|
|
* Create directory if it doesn't exist
|
|
*
|
|
* @param string $path Directory path
|
|
* @param int $permissions Directory permissions
|
|
* @return bool Success status
|
|
*/
|
|
function ensure_directory($path, $permissions = 0755)
|
|
{
|
|
if (!is_dir($path)) {
|
|
return mkdir($path, $permissions, true);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get relative path from base path
|
|
*
|
|
* @param string $path Full path
|
|
* @param string|null $base Base path (defaults to base_path())
|
|
* @return string Relative path
|
|
*/
|
|
function relative_path($path, $base = null)
|
|
{
|
|
if ($base === null) {
|
|
$base = base_path();
|
|
}
|
|
|
|
$base = rtrim($base, '/') . '/';
|
|
|
|
if (str_starts_with($path, $base)) {
|
|
return substr($path, strlen($base));
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Copy directory recursively
|
|
*
|
|
* @param string $source Source directory
|
|
* @param string $destination Destination directory
|
|
* @param array $ignore Patterns to ignore
|
|
* @return bool Success status
|
|
*/
|
|
function copy_directory($source, $destination, $ignore = [])
|
|
{
|
|
if (!is_dir($source)) {
|
|
return false;
|
|
}
|
|
|
|
if (!is_dir($destination)) {
|
|
mkdir($destination, 0755, true);
|
|
}
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::SELF_FIRST,
|
|
);
|
|
|
|
foreach ($iterator as $item) {
|
|
$source_path = $item->getPathname();
|
|
$relative = relative_path($source_path, $source);
|
|
$dest_path = $destination . '/' . $relative;
|
|
|
|
// Check ignore patterns
|
|
foreach ($ignore as $pattern) {
|
|
if (fnmatch($pattern, $relative)) {
|
|
continue 2;
|
|
}
|
|
}
|
|
|
|
if ($item->isDir()) {
|
|
if (!is_dir($dest_path)) {
|
|
mkdir($dest_path, 0755, true);
|
|
}
|
|
} else {
|
|
copy($source_path, $dest_path);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Recursively sort array by keys
|
|
*
|
|
* @param array $array Array to sort (by reference)
|
|
* @param int $sort_flags Sort flags
|
|
* @return bool Success status
|
|
*/
|
|
function ksort_recursive(&$array, $sort_flags = SORT_REGULAR)
|
|
{
|
|
if (!is_array($array)) {
|
|
return false;
|
|
}
|
|
|
|
if (is_associative_array($array)) {
|
|
ksort($array, $sort_flags);
|
|
} else {
|
|
sort($array, $sort_flags);
|
|
}
|
|
|
|
foreach ($array as &$value) {
|
|
if (is_array($value)) {
|
|
ksort_recursive($value, $sort_flags);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Convert snake_case to camelCase
|
|
*
|
|
* @param string $string Snake case string
|
|
* @param bool $capitalize_first Whether to capitalize first letter
|
|
* @return string Camel case string
|
|
*/
|
|
function snake_to_camel($string, $capitalize_first = false)
|
|
{
|
|
$result = str_replace('_', '', ucwords($string, '_'));
|
|
|
|
if (!$capitalize_first) {
|
|
$result = lcfirst($result);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Convert camelCase to snake_case
|
|
*
|
|
* @param string $string Camel case string
|
|
* @return string Snake case string
|
|
*/
|
|
function camel_to_snake($string)
|
|
{
|
|
return strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($string)));
|
|
}
|
|
|
|
/**
|
|
* Check if a value is "blank" (null, empty string, empty array, etc.)
|
|
*
|
|
* @param mixed $value Value to check
|
|
* @return bool True if blank
|
|
*/
|
|
function is_blank($value)
|
|
{
|
|
if (is_null($value)) {
|
|
return true;
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
return trim($value) === '';
|
|
}
|
|
|
|
if (is_numeric($value) || is_bool($value)) {
|
|
return false;
|
|
}
|
|
|
|
if ($value instanceof \Countable) {
|
|
return count($value) === 0;
|
|
}
|
|
|
|
return empty($value);
|
|
}
|
|
|
|
/**
|
|
* Get nested array value using "dot" notation
|
|
*
|
|
* @param array $array Source array
|
|
* @param string $key Dot notation key
|
|
* @param mixed $default Default value if not found
|
|
* @return mixed Value or default
|
|
*/
|
|
function array_get($array, $key, $default = null)
|
|
{
|
|
if (is_null($key)) {
|
|
return $array;
|
|
}
|
|
|
|
if (isset($array[$key])) {
|
|
return $array[$key];
|
|
}
|
|
|
|
foreach (explode('.', $key) as $segment) {
|
|
if (!is_array($array) || !array_key_exists($segment, $array)) {
|
|
return $default;
|
|
}
|
|
|
|
$array = $array[$segment];
|
|
}
|
|
|
|
return $array;
|
|
}
|
|
|
|
/**
|
|
* Set nested array value using "dot" notation
|
|
*
|
|
* @param array $array Target [by reference]
|
|
* @param string $key Dot notation key
|
|
* @param mixed $value Value to set
|
|
* @return void
|
|
*/
|
|
function array_set(&$array, $key, $value)
|
|
{
|
|
if (is_null($key)) {
|
|
$array = $value;
|
|
|
|
return;
|
|
}
|
|
|
|
$keys = explode('.', $key);
|
|
|
|
while (count($keys) > 1) {
|
|
$key = array_shift($keys);
|
|
|
|
if (!isset($array[$key]) || !is_array($array[$key])) {
|
|
$array[$key] = [];
|
|
}
|
|
|
|
$array = &$array[$key];
|
|
}
|
|
|
|
$array[array_shift($keys)] = $value;
|
|
}
|
|
|
|
/**
|
|
* Flatten a multi-dimensional array into a single level
|
|
*
|
|
* @param array $array Array to flatten
|
|
* @param int $depth Maximum depth to flatten
|
|
* @return array Flattened array
|
|
*/
|
|
function array_flatten($array, $depth = INF)
|
|
{
|
|
$result = [];
|
|
|
|
foreach ($array as $item) {
|
|
if (!is_array($item)) {
|
|
$result[] = $item;
|
|
} elseif ($depth === 1) {
|
|
$result = array_merge($result, array_values($item));
|
|
} else {
|
|
$result = array_merge($result, array_flatten($item, $depth - 1));
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Get the first element of an array that matches a condition
|
|
*
|
|
* @param array $array Source array
|
|
* @param callable|null $callback Optional filter callback
|
|
* @param mixed $default Default value if not found
|
|
* @return mixed First element or default
|
|
*/
|
|
function array_first($array, $callback = null, $default = null)
|
|
{
|
|
if (is_null($callback)) {
|
|
if (empty($array)) {
|
|
return $default;
|
|
}
|
|
|
|
foreach ($array as $item) {
|
|
return $item;
|
|
}
|
|
}
|
|
|
|
foreach ($array as $key => $value) {
|
|
if ($callback($value, $key)) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
/**
|
|
* Get the last element of an array that matches a condition
|
|
*
|
|
* @param array $array Source array
|
|
* @param callable|null $callback Optional filter callback
|
|
* @param mixed $default Default value if not found
|
|
* @return mixed Last element or default
|
|
*/
|
|
function array_last($array, $callback = null, $default = null)
|
|
{
|
|
if (is_null($callback)) {
|
|
return empty($array) ? $default : end($array);
|
|
}
|
|
|
|
return array_first(array_reverse($array, true), $callback, $default);
|
|
}
|
|
|
|
/**
|
|
* Create a temporary directory
|
|
*
|
|
* @param string $prefix Directory prefix
|
|
* @param string $base Base directory (defaults to sys temp)
|
|
* @return string|false Path to created directory or false on failure
|
|
*/
|
|
function make_temp_directory($prefix = 'rsx_', $base = null)
|
|
{
|
|
if ($base === null) {
|
|
$base = sys_get_temp_dir();
|
|
}
|
|
|
|
$attempts = 10;
|
|
while ($attempts-- > 0) {
|
|
$path = $base . '/' . $prefix . random_hash(8);
|
|
if (mkdir($path, 0755)) {
|
|
return $path;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the current site or a specific attribute from the current site.
|
|
*
|
|
* @param string|null $key
|
|
* @return Site|mixed|null
|
|
*/
|
|
// Not yet implemented
|
|
// function site($key = null)
|
|
// {
|
|
// if (is_null($key)) {
|
|
// return \App\RSpade\Core\Session\Session::get_site();
|
|
// }
|
|
//
|
|
// $current_site = \App\RSpade\Core\Session\Session::get_site();
|
|
// return $current_site?->getAttribute($key);
|
|
// }
|
|
|
|
/**
|
|
* Generate a URL for a specific site.
|
|
* This function allows generating cross-site URLs by temporarily switching context.
|
|
*
|
|
* @param int $site_id
|
|
* @param string $route
|
|
* @param array $parameters
|
|
* @param bool $absolute
|
|
* @return string
|
|
*/
|
|
// Not yet implemented
|
|
// function site_route($site_id, $route, $parameters = [], $absolute = true)
|
|
// {
|
|
// $original_site_id = \App\RSpade\Core\Session\Session::get_site_id();
|
|
//
|
|
// try {
|
|
// // Temporarily switch to target site context
|
|
// \App\RSpade\Core\Session\Session::set_site_id($site_id);
|
|
//
|
|
// // Generate the route with the target site context
|
|
// $url = route($route, $parameters, $absolute);
|
|
//
|
|
// return $url;
|
|
//
|
|
// } finally {
|
|
// // Always restore original site context
|
|
// \App\RSpade\Core\Session\Session::set_site_id($original_site_id);
|
|
// }
|
|
// }
|
|
|
|
/**
|
|
* Execute a shell command with pretty console output formatting.
|
|
* Shows the command in gray with a carat prefix, then the output in default color.
|
|
*
|
|
* @param string $command The shell command to execute
|
|
* @param bool $real_time Whether to show output in real-time (true) or after completion (false)
|
|
* @param bool $throw_on_error Whether to throw an exception on non-zero exit code
|
|
* @return array Returns array with 'output', 'error', and 'exit_code'
|
|
*/
|
|
function shell_exec_pretty($command, $real_time = true, $throw_on_error = false)
|
|
{
|
|
// ANSI color codes
|
|
$gray = "\033[90m";
|
|
$reset = "\033[0m";
|
|
$red = "\033[31m";
|
|
|
|
// Display the command being run
|
|
echo $gray . '> ' . $command . $reset . PHP_EOL;
|
|
|
|
if ($real_time) {
|
|
// Use passthru() for real-time output without proc_open() pipe buffer issues
|
|
// Redirect to temp file to capture output for return value
|
|
$temp_file = storage_path('rsx-tmp/shell_exec_pretty_' . uniqid() . '.txt');
|
|
|
|
// Use script command wrapper to show real-time output AND capture to file
|
|
// passthru() shows output but doesn't capture it, so we use tee to do both
|
|
$full_command = "($command 2>&1) | tee " . escapeshellarg($temp_file);
|
|
|
|
// passthru() displays output in real-time and returns the exit code via $exit_code
|
|
passthru($full_command, $exit_code);
|
|
|
|
// Read captured output from file
|
|
$output = '';
|
|
$error = '';
|
|
if (file_exists($temp_file)) {
|
|
$output = file_get_contents($temp_file);
|
|
unlink($temp_file); // Clean up
|
|
}
|
|
} else {
|
|
// Use shell_exec for simple execution
|
|
$full_command = $command . ' 2>&1';
|
|
$output = shell_exec($full_command);
|
|
$exit_code = 0; // shell_exec doesn't provide exit codes
|
|
$error = '';
|
|
|
|
// Display output
|
|
if ($output !== null) {
|
|
echo $output;
|
|
}
|
|
}
|
|
|
|
// Check exit code
|
|
if ($exit_code !== 0 && $throw_on_error) {
|
|
$error_msg = "Command failed with exit code $exit_code: $command";
|
|
if ($error) {
|
|
$error_msg .= "\nError output: $error";
|
|
}
|
|
|
|
throw new RuntimeException($error_msg);
|
|
}
|
|
|
|
return [
|
|
'output' => trim($output),
|
|
'error' => trim($error),
|
|
'exit_code' => $exit_code,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if a command exists in the system PATH.
|
|
*
|
|
* @param string $command
|
|
* @return bool
|
|
*/
|
|
function command_exists($command)
|
|
{
|
|
$which = PHP_OS_FAMILY === 'Windows' ? 'where' : 'which';
|
|
$result = shell_exec("$which $command 2>&1");
|
|
|
|
return !empty($result) && !str_contains($result, 'not found') && !str_contains($result, 'Could not find');
|
|
}
|
|
|
|
/**
|
|
* Execute command without exec()'s output truncation issues
|
|
* Drop-in replacement for exec() using shell_exec() and file redirection
|
|
*
|
|
* exec() has a critical flaw: it reads command output line-by-line into an array,
|
|
* which can hit memory/buffer limits on large outputs (>1MB typical), causing
|
|
* SILENT TRUNCATION without throwing errors or exceptions.
|
|
*
|
|
* \exec_safe() uses shell_exec() internally which handles unlimited output without
|
|
* pipe buffer truncation issues, while maintaining the exact same signature as exec().
|
|
*
|
|
* Usage:
|
|
* // Before:
|
|
* exec('git status 2>&1', $output, $return_var);
|
|
*
|
|
* // After:
|
|
* \exec_safe('git status 2>&1', $output, $return_var);
|
|
*
|
|
* @param string $command Command to execute
|
|
* @param array &$output Output lines (populated by reference like exec())
|
|
* @param int &$return_var Return code (populated by reference like exec())
|
|
* @return string|false Last line of output, or false on failure (like exec())
|
|
*/
|
|
function exec_safe(string $command, array &$output = [], int &$return_var = 0): string|false
|
|
{
|
|
// Use shell_exec() for reliable output capture without pipe buffer truncation
|
|
// shell_exec() doesn't provide exit codes, so use exec() with file redirection for that
|
|
$temp_file = storage_path('rsx-tmp/exec_safe_' . uniqid() . '.txt');
|
|
|
|
// Redirect output to temp file to get both output and exit code reliably
|
|
$full_command = "($command) > " . escapeshellarg($temp_file) . " 2>&1; echo $?";
|
|
|
|
// Execute and capture just the exit code (last line)
|
|
$result = shell_exec($full_command);
|
|
$return_var = (int)trim($result);
|
|
|
|
// Read the full output from file
|
|
$combined = '';
|
|
if (file_exists($temp_file)) {
|
|
$combined = file_get_contents($temp_file);
|
|
unlink($temp_file); // Clean up
|
|
}
|
|
|
|
if ($combined === false) {
|
|
$return_var = -1;
|
|
$output = [];
|
|
return false;
|
|
}
|
|
|
|
// Split into lines like exec() does
|
|
$output = $combined ? explode("\n", trim($combined)) : [];
|
|
|
|
// Return last line like exec() does
|
|
return empty($output) ? '' : end($output);
|
|
}
|
|
|
|
/**
|
|
* Debug dump and die - Enhanced var_dump/die() replacement
|
|
*
|
|
* Outputs debug information with file/line location, values, and stack trace.
|
|
* This is the preferred method for temporary debugging output.
|
|
*
|
|
* @param mixed ...$values Any number of values to debug
|
|
* @return void (never returns - calls die())
|
|
*/
|
|
function rsx_dump_die(...$values)
|
|
{
|
|
\App\RSpade\Core\Debug\Debugger::rsx_dump_die(...$values);
|
|
}
|
|
|
|
/**
|
|
* Get the RSX ID of the current view for use as a body class
|
|
*
|
|
* This is used in layout files to add the view's RSX ID as a CSS class
|
|
* to the body tag, enabling view-specific styling.
|
|
*
|
|
* @return string The RSX ID or empty string if not set
|
|
*/
|
|
function rsx_body_class()
|
|
{
|
|
return \Illuminate\Support\Facades\View::shared('rsx_view_id', '');
|
|
}
|
|
|
|
/**
|
|
* Create an authentication required response
|
|
*
|
|
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
|
|
* - HTTP requests: Sets flash alert and redirects to login
|
|
* - AJAX requests: Returns JSON error with success:false
|
|
*
|
|
* @param string $reason The reason authentication is required
|
|
* @param string|null $redirect The URL to redirect to (default: /login)
|
|
* @return \App\RSpade\Core\Response\Auth_Required_Response
|
|
*/
|
|
function response_auth_required($reason = 'Authentication Required', $redirect = '/login')
|
|
{
|
|
return new \App\RSpade\Core\Response\Auth_Required_Response($reason, $redirect);
|
|
}
|
|
|
|
/**
|
|
* Create an unauthorized response
|
|
*
|
|
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
|
|
* - HTTP requests: Sets flash alert and redirects or throws exception
|
|
* - AJAX requests: Returns JSON error with success:false
|
|
*
|
|
* @param string $reason The reason for unauthorized access
|
|
* @param string|null $redirect The URL to redirect to (null throws exception)
|
|
* @return \App\RSpade\Core\Response\Unauthorized_Response
|
|
*/
|
|
function response_unauthorized($reason = 'Unauthorized', $redirect = null)
|
|
{
|
|
return new \App\RSpade\Core\Response\Unauthorized_Response($reason, $redirect);
|
|
}
|
|
|
|
/**
|
|
* Create a form error response
|
|
*
|
|
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
|
|
* - HTTP requests: Sets flash alert and redirects back to same URL as GET
|
|
* - AJAX requests: Returns JSON error with success:false and details
|
|
*
|
|
* @param string $reason The error message
|
|
* @param array $details Additional error details
|
|
* @return \App\RSpade\Core\Response\Form_Error_Response
|
|
*/
|
|
function response_form_error($reason = 'An error has occurred.', $details = [])
|
|
{
|
|
return new \App\RSpade\Core\Response\Form_Error_Response($reason, $details);
|
|
}
|
|
|
|
/**
|
|
* Create a fatal error response
|
|
*
|
|
* Returns a special response object that always throws an exception
|
|
* in both HTTP and AJAX contexts
|
|
*
|
|
* @param string $reason The error message
|
|
* @param array $details Additional error details
|
|
* @return \App\RSpade\Core\Response\Fatal_Error_Response
|
|
*/
|
|
function response_fatal_error($reason = 'An error has occurred.', $details = [])
|
|
{
|
|
return new \App\RSpade\Core\Response\Fatal_Error_Response($reason, $details);
|
|
}
|
|
|
|
/**
|
|
* Check if the current request is from a loopback IP address
|
|
*
|
|
* Returns true only if:
|
|
* - Request is from localhost, 127.0.0.1, or ::1 (IPv6 loopback)
|
|
* - No proxy headers (X-Real-IP, X-Forwarded-For) are present
|
|
*
|
|
* Used to ensure Playwright test headers can only be used from local connections.
|
|
*
|
|
* @return bool True if request is from loopback without proxy headers
|
|
*/
|
|
function is_loopback_ip(): bool
|
|
{
|
|
$request = request();
|
|
if (!$request) {
|
|
return false;
|
|
}
|
|
|
|
// Check for proxy headers - if present, not a direct loopback connection
|
|
if ($request->hasHeader('X-Real-IP') ||
|
|
$request->hasHeader('X-Forwarded-For') ||
|
|
$request->hasHeader('X-Forwarded-Host') ||
|
|
$request->hasHeader('X-Forwarded-Proto')) {
|
|
return false;
|
|
}
|
|
|
|
// Get the client IP
|
|
$ip = $request->ip();
|
|
|
|
// Check for loopback addresses
|
|
// IPv4 loopback: 127.0.0.1
|
|
// IPv6 loopback: ::1
|
|
// Hostname: localhost
|
|
$loopback_addresses = [
|
|
'127.0.0.1',
|
|
'::1',
|
|
'localhost',
|
|
];
|
|
|
|
return in_array($ip, $loopback_addresses, true);
|
|
}
|
|
|
|
/**
|
|
* Generate a hash for a file suitable for build/cache invalidation
|
|
*
|
|
* Uses different strategies based on environment:
|
|
* - Development: Fast hash based on path, size, and modification time
|
|
* - Production: Thorough hash based on path and file contents
|
|
*
|
|
* This function is used by the manifest and bundle systems to detect
|
|
* when files have changed and need recompilation.
|
|
*
|
|
* @param string $file_path The absolute path to the file
|
|
* @return string A hash string representing the file state
|
|
*/
|
|
function _rsx_file_hash_for_build($file_path)
|
|
{
|
|
if (!file_exists($file_path)) {
|
|
shouldnt_happen("File does not exist for hashing: {$file_path}");
|
|
}
|
|
|
|
// In development mode, use a fast hash based on metadata
|
|
if (!app()->environment('production')) {
|
|
$hash_data = [
|
|
'path' => $file_path,
|
|
'size' => filesize($file_path),
|
|
'mtime' => filemtime($file_path),
|
|
];
|
|
|
|
return md5(json_encode($hash_data));
|
|
}
|
|
|
|
// In production mode, use a thorough hash based on content
|
|
$hash_data = [
|
|
'path' => $file_path,
|
|
'content' => file_get_contents($file_path),
|
|
];
|
|
|
|
return hash('sha512', json_encode($hash_data));
|
|
}
|
|
|
|
/**
|
|
* Convert text to HTML preserving whitespace and indentation
|
|
*
|
|
* Converts plain text to HTML that displays with proper formatting:
|
|
* - HTML special characters are escaped
|
|
* - Leading spaces on each line are converted to
|
|
* - Newlines are converted to <br> tags
|
|
* - Trailing whitespace on lines is trimmed
|
|
*
|
|
* This is useful for displaying source code or formatted text in HTML
|
|
* where you want to preserve the indentation and line breaks.
|
|
*
|
|
* @param string $text The plain text to convert
|
|
* @return string HTML-formatted text
|
|
*/
|
|
function text_to_html_with_whitespace(string $text): string
|
|
{
|
|
// First, escape HTML special characters to prevent XSS
|
|
$text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
// Split into lines
|
|
$lines = explode("\n", $text);
|
|
|
|
// Process each line
|
|
$processed_lines = [];
|
|
foreach ($lines as $line) {
|
|
// Trim trailing whitespace only (preserve leading spaces)
|
|
$line = rtrim($line);
|
|
|
|
// Count leading spaces
|
|
$leading_spaces = strlen($line) - strlen(ltrim($line));
|
|
|
|
if ($leading_spaces > 0) {
|
|
// Replace leading spaces with
|
|
$spaces_html = str_repeat(' ', $leading_spaces);
|
|
$line = $spaces_html . substr($line, $leading_spaces);
|
|
}
|
|
|
|
$processed_lines[] = $line;
|
|
}
|
|
|
|
// Join lines with <br>\n
|
|
return implode("<br>\n", $processed_lines);
|
|
}
|
|
|
|
/**
|
|
* Normalize a path resolving . and .. components without resolving symlinks
|
|
*
|
|
* Unlike PHP's realpath(), this function normalizes paths by resolving . and ..
|
|
* components but does NOT follow symlinks. This is important when working with
|
|
* symlinked directories where you need the logical path, not the physical path.
|
|
*
|
|
* Behavior:
|
|
* - Resolves . (current directory) and .. (parent directory) components
|
|
* - Does NOT resolve symlinks (unlike realpath())
|
|
* - Converts relative paths to absolute by prepending base_path()
|
|
* - Returns normalized absolute path, or false if file doesn't exist
|
|
*
|
|
* Examples:
|
|
* - rsxrealpath('/var/www/html/system/rsx/foo/../bar')
|
|
* => '/var/www/html/system/rsx/bar'
|
|
*
|
|
* - rsxrealpath('rsx/foo/../bar')
|
|
* => '/var/www/html/rsx/foo/../bar' (after base_path prepend)
|
|
* => '/var/www/html/rsx/bar'
|
|
*
|
|
* - If /var/www/html/system/rsx is a symlink to /var/www/html/rsx:
|
|
* rsxrealpath('/var/www/html/system/rsx/bar')
|
|
* => '/var/www/html/system/rsx/bar' (keeps symlink path, unlike realpath)
|
|
*
|
|
* @param string $path The path to normalize
|
|
* @return string|false The normalized absolute path, or false if file doesn't exist
|
|
*/
|
|
function rsxrealpath(string $path): string|false
|
|
{
|
|
// Convert relative path to absolute
|
|
if (!str_starts_with($path, '/')) {
|
|
$path = base_path() . '/' . $path;
|
|
}
|
|
|
|
// Split path into components
|
|
$parts = explode('/', $path);
|
|
$result = [];
|
|
|
|
foreach ($parts as $part) {
|
|
if ($part === '' || $part === '.') {
|
|
// Skip empty parts and current directory references
|
|
continue;
|
|
} elseif ($part === '..') {
|
|
// Go up one directory (remove last component)
|
|
if (!empty($result)) {
|
|
array_pop($result);
|
|
}
|
|
} else {
|
|
// Regular path component
|
|
$result[] = $part;
|
|
}
|
|
}
|
|
|
|
// Rebuild path with leading slash
|
|
$normalized = '/' . implode('/', $result);
|
|
|
|
// Check if path exists (like realpath does)
|
|
if (!file_exists($normalized)) {
|
|
return false;
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* Convert bytes to human-readable format
|
|
*
|
|
* Converts a number of bytes into a human-readable string with appropriate unit suffix.
|
|
*
|
|
* @param int $bytes The number of bytes
|
|
* @param int $precision Number of decimal places (default: 2)
|
|
* @return string Formatted string (e.g., "1.5 MB", "342 B")
|
|
*/
|
|
function bytes_to_human($bytes, $precision = 2)
|
|
{
|
|
if (!is_numeric($bytes)) {
|
|
return "---";
|
|
}
|
|
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
|
|
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
|
$bytes /= 1024;
|
|
}
|
|
|
|
return round($bytes, $precision) . ' ' . $units[$i];
|
|
}
|
|
|
|
/**
|
|
* Converts a duration from seconds to a human-readable format.
|
|
*
|
|
* @param int $seconds The duration in seconds.
|
|
* @param bool $round_to_whole_value If true, returns only the largest unit
|
|
* @return string The formatted duration.
|
|
*
|
|
* The function returns:
|
|
* - "X seconds" if the duration is less than 60 seconds,
|
|
* - "X minutes and Y seconds" if less than 10 minutes,
|
|
* - "X minutes" if less than 1 hour,
|
|
* - "X hours and Y minutes" otherwise.
|
|
*/
|
|
function duration_to_human($seconds, $round_to_whole_value = false)
|
|
{
|
|
if (!is_numeric($seconds)) {
|
|
return "---";
|
|
}
|
|
|
|
$years = intdiv($seconds, 31536000); // 365 days
|
|
$months = intdiv($seconds % 31536000, 2592000); // 30 days
|
|
$weeks = intdiv($seconds % 2592000, 604800); // 7 days
|
|
$days = intdiv($seconds % 604800, 86400); // 24 hours
|
|
$hours = intdiv($seconds % 86400, 3600);
|
|
$minutes = intdiv($seconds % 3600, 60);
|
|
$remaining_seconds = $seconds % 60;
|
|
|
|
$parts = [];
|
|
if ($years > 0) {
|
|
$parts[] = $years . " year" . ($years != 1 ? "s" : "");
|
|
}
|
|
if ($months > 0) {
|
|
$parts[] = $months . " month" . ($months != 1 ? "s" : "");
|
|
}
|
|
if ($weeks > 0) {
|
|
$parts[] = $weeks . " week" . ($weeks != 1 ? "s" : "");
|
|
}
|
|
if ($days > 0) {
|
|
$parts[] = $days . " day" . ($days != 1 ? "s" : "");
|
|
}
|
|
if ($hours > 0) {
|
|
$parts[] = $hours . " hour" . ($hours != 1 ? "s" : "");
|
|
}
|
|
if ($minutes > 0) {
|
|
$parts[] = $minutes . " minute" . ($minutes != 1 ? "s" : "");
|
|
}
|
|
if ($remaining_seconds > 0) {
|
|
$parts[] = $remaining_seconds . " second" . ($remaining_seconds != 1 ? "s" : "");
|
|
}
|
|
|
|
if ($round_to_whole_value) {
|
|
return count($parts) > 0 ? $parts[0] : "less than a second";
|
|
} else {
|
|
return count($parts) > 1 ? $parts[0] . " and " . $parts[1] : (count($parts) > 0 ? $parts[0] : "less than a second");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @return string|null Short URL without protocol
|
|
*/
|
|
function full_url_to_short_url(?string $url): ?string
|
|
{
|
|
if ($url === null || $url === '') {
|
|
return $url;
|
|
}
|
|
|
|
// Remove http:// or https:// from the beginning
|
|
if (stripos($url, 'http://') === 0) {
|
|
$url = substr($url, 7);
|
|
} elseif (stripos($url, 'https://') === 0) {
|
|
$url = substr($url, 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 (substr($url, -1) === '/' && substr_count($url, '/') === 1) {
|
|
$url = rtrim($url, '/');
|
|
}
|
|
|
|
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
|
|
* @return string|null Full URL with protocol
|
|
*/
|
|
function short_url_to_full_url(?string $url): ?string
|
|
{
|
|
if ($url === null || $url === '') {
|
|
return $url;
|
|
}
|
|
|
|
// Check if URL already has a protocol
|
|
if (stripos($url, 'http://') === 0 || stripos($url, '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
|
|
$without_protocol = preg_replace('#^https?://#i', '', $full_url);
|
|
if (strpos($without_protocol, '/') === false) {
|
|
$full_url .= '/';
|
|
}
|
|
|
|
return $full_url;
|
|
}
|
|
|
|
/**
|
|
* Validate a short URL format
|
|
*
|
|
* Validates that a URL (without protocol) looks like a valid domain.
|
|
* Requirements:
|
|
* - Must contain at least one dot (.)
|
|
* - Must not contain spaces
|
|
* - Empty strings are considered valid (optional field)
|
|
*
|
|
* @param string|null $url Short URL to validate
|
|
* @return bool True if valid, false otherwise
|
|
*/
|
|
function validate_short_url(?string $url): bool
|
|
{
|
|
// Empty strings are valid (optional field)
|
|
if ($url === null || $url === '') {
|
|
return true;
|
|
}
|
|
|
|
// Must not contain spaces
|
|
if (strpos($url, ' ') !== false) {
|
|
return false;
|
|
}
|
|
|
|
// Must contain at least one dot (domain.extension)
|
|
if (strpos($url, '.') === false) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|