Fix code quality violations and enhance ROUTE-EXISTS-01 rule
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>
This commit is contained in:
@@ -343,6 +343,12 @@ class Ajax
|
||||
$json_response['console_debug'] = $console_messages;
|
||||
}
|
||||
|
||||
// Add flash_alerts if any pending for current session
|
||||
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
||||
if (!empty($flash_messages)) {
|
||||
$json_response['flash_alerts'] = $flash_messages;
|
||||
}
|
||||
|
||||
return response()->json($json_response);
|
||||
}
|
||||
|
||||
@@ -436,6 +442,12 @@ class Ajax
|
||||
$json_response['console_debug'] = $console_messages;
|
||||
}
|
||||
|
||||
// Add flash_alerts if any pending for current session
|
||||
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
||||
if (!empty($flash_messages)) {
|
||||
$json_response['flash_alerts'] = $flash_messages;
|
||||
}
|
||||
|
||||
return $json_response;
|
||||
}
|
||||
|
||||
|
||||
@@ -2031,7 +2031,18 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
// Get file data from manifest
|
||||
$file_data = $manifest_files[$relative] ?? null;
|
||||
|
||||
// If we have class information from manifest, use it
|
||||
// All JavaScript files MUST be in manifest
|
||||
if ($file_data === null) {
|
||||
throw new \RuntimeException(
|
||||
"JavaScript file in bundle but not in manifest (this should never happen):\n" .
|
||||
"File: {$file}\n" .
|
||||
"Relative: {$relative}\n" .
|
||||
"Bundle: {$this->bundle_name}\n" .
|
||||
"Manifest has " . count($manifest_files) . " files total"
|
||||
);
|
||||
}
|
||||
|
||||
// If file has a class, add to class definitions
|
||||
if (!empty($file_data['class'])) {
|
||||
$class_name = $file_data['class'];
|
||||
$extends_class = $file_data['extends'] ?? null;
|
||||
@@ -2046,19 +2057,8 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
'extends' => $extends_class,
|
||||
'decorators' => $file_data['method_decorators'] ?? null,
|
||||
];
|
||||
} elseif (file_exists($file)) {
|
||||
// Parse the file directly for classes
|
||||
$content = file_get_contents($file);
|
||||
$classes = $this->_parse_javascript_classes($content);
|
||||
|
||||
foreach ($classes as $class_info) {
|
||||
$class_definitions[$class_info['name']] = [
|
||||
'name' => $class_info['name'],
|
||||
'extends' => $class_info['extends'],
|
||||
'decorators' => null, // No decorator info when parsing directly
|
||||
];
|
||||
}
|
||||
}
|
||||
// Otherwise, it's a standalone function file - no manifest registration needed
|
||||
}
|
||||
|
||||
// If no classes found, return null
|
||||
@@ -2105,27 +2105,6 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
return $this->_write_temp_file($js_code, 'js');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JavaScript content to extract class definitions
|
||||
*/
|
||||
protected function _parse_javascript_classes(string $content): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
// Match ES6 class declarations with optional extends
|
||||
$pattern = '/(?:^|\s)class\s+([A-Z]\w*)(?:\s+extends\s+([A-Z]\w*))?/m';
|
||||
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$classes[] = [
|
||||
'name' => $match[1],
|
||||
'extends' => isset($match[2]) ? $match[2] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JavaScript runner for automatic class initialization
|
||||
|
||||
@@ -24,6 +24,8 @@ class Core_Bundle extends Rsx_Bundle_Abstract
|
||||
__DIR__,
|
||||
'app/RSpade/Core/Js',
|
||||
'app/RSpade/Core/Data',
|
||||
'app/RSpade/Core/SPA',
|
||||
'app/RSpade/Lib',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -255,6 +255,14 @@ abstract class Rsx_Bundle_Abstract
|
||||
// Add build_key (always included in both dev and production)
|
||||
$rsxapp_data['build_key'] = $manifest_hash;
|
||||
|
||||
// Add session_hash if session exists (hashed for scoping, non-reversible)
|
||||
$session_token = $_COOKIE['rsx'] ?? null;
|
||||
if ($session_token) {
|
||||
$rsxapp_data['session_hash'] = hash_hmac('sha256', $session_token, config('app.key'));
|
||||
} else {
|
||||
$rsxapp_data['session_hash'] = null;
|
||||
}
|
||||
|
||||
// Add bundle data if present
|
||||
if (!empty($compiled['config'])) {
|
||||
$rsxapp_data = array_merge($rsxapp_data, $compiled['bundle_data'] ?? []);
|
||||
@@ -265,6 +273,7 @@ abstract class Rsx_Bundle_Abstract
|
||||
$rsxapp_data['current_controller'] = \App\RSpade\Core\Rsx::get_current_controller();
|
||||
$rsxapp_data['current_action'] = \App\RSpade\Core\Rsx::get_current_action();
|
||||
$rsxapp_data['is_auth'] = Session::is_logged_in();
|
||||
$rsxapp_data['is_spa'] = \App\RSpade\Core\Rsx::is_spa();
|
||||
$rsxapp_data['ajax_disable_batching'] = config('rsx.development.ajax_disable_batching', false);
|
||||
|
||||
// Add current params (always set to reduce state variations)
|
||||
@@ -286,6 +295,12 @@ abstract class Rsx_Bundle_Abstract
|
||||
$rsxapp_data['page_data'] = \App\RSpade\Core\View\PageData::get();
|
||||
}
|
||||
|
||||
// Add flash_alerts if any pending for current session
|
||||
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
||||
if (!empty($flash_messages)) {
|
||||
$rsxapp_data['flash_alerts'] = $flash_messages;
|
||||
}
|
||||
|
||||
// Add console_debug config in non-production mode
|
||||
if (!app()->environment('production')) {
|
||||
$console_debug_config = config('rsx.console_debug', []);
|
||||
@@ -641,8 +656,8 @@ abstract class Rsx_Bundle_Abstract
|
||||
}
|
||||
}
|
||||
|
||||
// Throw error if not covered
|
||||
if (!$is_covered) {
|
||||
// Throw error if not covered (skip for framework views in app/RSpade/)
|
||||
if (!$is_covered && !str_starts_with($view_path, 'app/RSpade/')) {
|
||||
// Bundle doesn't include the view directory
|
||||
BundleErrors::view_not_covered($view_path, $view_dir, $bundle_class, $include_paths);
|
||||
}
|
||||
|
||||
@@ -200,8 +200,12 @@ class SqlQueryTransformer
|
||||
*/
|
||||
private static function __validate_forbidden_types(string $query): void
|
||||
{
|
||||
// Strip comments and quoted strings before validation to avoid false positives
|
||||
// (e.g., "TIME" in a COMMENT won't trigger the TIME type check)
|
||||
$query_without_strings = self::__strip_comments_and_strings($query);
|
||||
|
||||
// Check for ENUM
|
||||
if (preg_match('/\bENUM\s*\(/i', $query)) {
|
||||
if (preg_match('/\bENUM\s*\(/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"ENUM column type is forbidden in RSpade. Use VARCHAR with validation instead.\n" .
|
||||
"ENUMs cannot be modified without table locks and cause deployment issues.\n" .
|
||||
@@ -210,7 +214,7 @@ class SqlQueryTransformer
|
||||
}
|
||||
|
||||
// Check for SET
|
||||
if (preg_match('/\bSET\s*\(/i', $query)) {
|
||||
if (preg_match('/\bSET\s*\(/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"SET column type is forbidden in RSpade. Use JSON or a separate table instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
@@ -218,7 +222,7 @@ class SqlQueryTransformer
|
||||
}
|
||||
|
||||
// Check for YEAR
|
||||
if (preg_match('/\bYEAR\b/i', $query)) {
|
||||
if (preg_match('/\bYEAR\b/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"YEAR column type is forbidden in RSpade. Use INT or DATE instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
@@ -226,14 +230,102 @@ class SqlQueryTransformer
|
||||
}
|
||||
|
||||
// Check for TIME (but allow DATETIME and TIMESTAMP)
|
||||
if (preg_match('/\bTIME\b(?!STAMP)/i', $query)) {
|
||||
throw new \RuntimeException(
|
||||
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
);
|
||||
if (preg_match('/\bTIME\b(?!STAMP)/i', $query_without_strings)) {
|
||||
// Additional check: make sure it's not part of DATETIME
|
||||
if (!preg_match('/\bDATETIME\b/i', $query_without_strings) ||
|
||||
preg_match('/[,\s]\s*TIME\s+/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip comments and quoted strings from SQL query
|
||||
*
|
||||
* Removes:
|
||||
* - Single-quoted strings
|
||||
* - Double-quoted strings
|
||||
* - SQL comments (both single-line and multi-line)
|
||||
*
|
||||
* This prevents false positives in validation (e.g., 'TIME' in a comment)
|
||||
*
|
||||
* @param string $query The SQL query
|
||||
* @return string Query with comments and strings removed
|
||||
*/
|
||||
private static function __strip_comments_and_strings(string $query): string
|
||||
{
|
||||
$result = '';
|
||||
$len = strlen($query);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $len) {
|
||||
// Check for single-line comment --
|
||||
if ($i < $len - 1 && $query[$i] === '-' && $query[$i + 1] === '-') {
|
||||
// Skip until newline
|
||||
while ($i < $len && $query[$i] !== "\n") {
|
||||
$i++;
|
||||
}
|
||||
$i++; // Skip the newline
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for multi-line comment /* */
|
||||
if ($i < $len - 1 && $query[$i] === '/' && $query[$i + 1] === '*') {
|
||||
// Skip until */
|
||||
$i += 2;
|
||||
while ($i < $len - 1 && !($query[$i] === '*' && $query[$i + 1] === '/')) {
|
||||
$i++;
|
||||
}
|
||||
$i += 2; // Skip the */
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for single-quoted string
|
||||
if ($query[$i] === "'") {
|
||||
$i++; // Skip opening quote
|
||||
while ($i < $len) {
|
||||
if ($query[$i] === "'") {
|
||||
// Check for escaped quote ''
|
||||
if ($i + 1 < $len && $query[$i + 1] === "'") {
|
||||
$i += 2; // Skip escaped quote
|
||||
} else {
|
||||
$i++; // Skip closing quote
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for double-quoted string
|
||||
if ($query[$i] === '"') {
|
||||
$i++; // Skip opening quote
|
||||
while ($i < $len) {
|
||||
if ($query[$i] === '"') {
|
||||
$i++; // Skip closing quote
|
||||
break;
|
||||
} else if ($query[$i] === '\\' && $i + 1 < $len) {
|
||||
$i += 2; // Skip escaped character
|
||||
} else {
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular character - keep it
|
||||
$result .= $query[$i];
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate MySQL syntax patterns that commonly cause issues
|
||||
*
|
||||
|
||||
@@ -122,7 +122,7 @@ class Dispatcher
|
||||
|
||||
console_debug('DISPATCH', 'Matched default route pattern:', $controller_name, '::', $action_name);
|
||||
|
||||
// Try to find the controller using manifest
|
||||
// First try to find as PHP controller
|
||||
try {
|
||||
$metadata = Manifest::php_get_metadata_by_class($controller_name);
|
||||
$controller_fqcn = $metadata['fqcn'];
|
||||
@@ -173,7 +173,56 @@ class Dispatcher
|
||||
return redirect($proper_url, 302);
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
console_debug('DISPATCH', 'Controller not found in manifest:', $controller_name);
|
||||
console_debug('DISPATCH', 'Not a PHP controller, checking if SPA action:', $controller_name);
|
||||
|
||||
// Not found as PHP controller - check if it's a SPA action
|
||||
try {
|
||||
$is_spa_action = Manifest::js_is_subclass_of($controller_name, 'Spa_Action');
|
||||
|
||||
if ($is_spa_action) {
|
||||
console_debug('DISPATCH', 'Found SPA action class:', $controller_name);
|
||||
|
||||
// Get the file path for this JS class
|
||||
$file_path = Manifest::js_find_class($controller_name);
|
||||
|
||||
// Get file metadata which contains decorator information
|
||||
$file_data = Manifest::get_file($file_path);
|
||||
|
||||
if (!$file_data) {
|
||||
console_debug('DISPATCH', 'SPA action metadata not found:', $controller_name);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract route pattern from @route() decorator
|
||||
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
|
||||
$route_pattern = null;
|
||||
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
|
||||
foreach ($file_data['decorators'] as $decorator) {
|
||||
if (isset($decorator[0]) && $decorator[0] === 'route') {
|
||||
if (isset($decorator[1][0])) {
|
||||
$route_pattern = $decorator[1][0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($route_pattern) {
|
||||
// Generate proper URL for the SPA action
|
||||
$params = array_merge($extra_params, $request->query->all());
|
||||
$proper_url = Rsx::Route($controller_name, $action_name, $params);
|
||||
|
||||
console_debug('DISPATCH', 'Redirecting to SPA action route:', $proper_url);
|
||||
|
||||
return redirect($proper_url, 302);
|
||||
} else {
|
||||
console_debug('DISPATCH', 'SPA action missing @route() decorator:', $controller_name);
|
||||
}
|
||||
}
|
||||
} catch (\RuntimeException $spa_e) {
|
||||
console_debug('DISPATCH', 'Not a SPA action either:', $controller_name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -246,7 +295,8 @@ class Dispatcher
|
||||
Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params));
|
||||
|
||||
// Set current controller and action in Rsx for tracking
|
||||
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params);
|
||||
$route_type = $route_match['type'] ?? 'standard';
|
||||
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type);
|
||||
|
||||
// Load and validate handler class
|
||||
static::__load_handler_class($handler_class);
|
||||
@@ -339,93 +389,78 @@ class Dispatcher
|
||||
if (empty($routes)) {
|
||||
\Log::debug('Manifest::get_routes() returned empty array');
|
||||
console_debug('DISPATCH', 'Warning: got 0 routes from Manifest::get_routes()');
|
||||
} else {
|
||||
\Log::debug('Manifest has ' . count($routes) . ' route types');
|
||||
// Log details for debugging but don't output to console
|
||||
foreach ($routes as $type => $type_routes) {
|
||||
\Log::debug("Route type '$type' has " . count($type_routes) . ' patterns');
|
||||
// Show first few patterns for debugging in logs only
|
||||
$patterns = array_slice(array_keys($type_routes), 0, 5);
|
||||
\Log::debug(' First patterns: ' . implode(', ', $patterns));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort handler types by priority
|
||||
$sorted_types = array_keys(static::$handler_priorities);
|
||||
usort($sorted_types, function ($a, $b) {
|
||||
return static::$handler_priorities[$a] - static::$handler_priorities[$b];
|
||||
});
|
||||
\Log::debug('Manifest has ' . count($routes) . ' routes');
|
||||
|
||||
// Collect all matching routes
|
||||
$matches = [];
|
||||
// Get all patterns and sort by priority
|
||||
$patterns = array_keys($routes);
|
||||
$patterns = RouteResolver::sort_by_priority($patterns);
|
||||
|
||||
// Try each handler type in priority order
|
||||
foreach ($sorted_types as $type) {
|
||||
if (!isset($routes[$type])) {
|
||||
// Try to match each pattern
|
||||
foreach ($patterns as $pattern) {
|
||||
$route = $routes[$pattern];
|
||||
|
||||
// Check if HTTP method is supported
|
||||
if (!in_array($method, $route['methods'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type_routes = $routes[$type];
|
||||
// Try to match the URL
|
||||
$params = RouteResolver::match_with_query($url, $pattern);
|
||||
|
||||
// Get all patterns for this type
|
||||
$patterns = array_keys($type_routes);
|
||||
if ($params !== false) {
|
||||
// Found a match - verify the method has the required attribute
|
||||
$class_fqcn = $route['class'];
|
||||
$method_name = $route['method'];
|
||||
|
||||
// Sort patterns by priority
|
||||
$patterns = RouteResolver::sort_by_priority($patterns);
|
||||
// Get method metadata from manifest
|
||||
$class_metadata = Manifest::php_get_metadata_by_fqcn($class_fqcn);
|
||||
$method_metadata = $class_metadata['public_static_methods'][$method_name] ?? null;
|
||||
|
||||
// Try to match each pattern
|
||||
foreach ($patterns as $pattern) {
|
||||
$route_info = $type_routes[$pattern];
|
||||
if (!$method_metadata) {
|
||||
throw new \RuntimeException(
|
||||
"Route method not found in manifest: {$class_fqcn}::{$method_name}\n" .
|
||||
"Pattern: {$pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if method is supported
|
||||
if (isset($route_info[$method])) {
|
||||
// Try to match the URL
|
||||
$params = RouteResolver::match_with_query($url, $pattern);
|
||||
// Check for Route or SPA attribute
|
||||
$attributes = $method_metadata['attributes'] ?? [];
|
||||
$has_route = false;
|
||||
|
||||
if ($params !== false) {
|
||||
// Handle new structure where each method can have multiple handlers
|
||||
$handlers = $route_info[$method];
|
||||
|
||||
// If it's not an array of handlers, convert it (backwards compatibility)
|
||||
if (!isset($handlers[0])) {
|
||||
$handlers = [$handlers];
|
||||
}
|
||||
|
||||
// Add all matching handlers
|
||||
foreach ($handlers as $handler) {
|
||||
$matches[] = [
|
||||
'type' => $type,
|
||||
'pattern' => $pattern,
|
||||
'class' => $handler['class'],
|
||||
'method' => $handler['method'],
|
||||
'params' => $params,
|
||||
'file' => $handler['file'] ?? null,
|
||||
'require' => $handler['require'] ?? [],
|
||||
];
|
||||
}
|
||||
foreach ($attributes as $attr_name => $attr_instances) {
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route' ||
|
||||
str_ends_with($attr_name, '\\SPA') || $attr_name === 'SPA') {
|
||||
$has_route = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate routes
|
||||
if (count($matches) > 1) {
|
||||
$error_msg = "Multiple routes match the request '{$method} {$url}':\n\n";
|
||||
foreach ($matches as $match) {
|
||||
$error_msg .= " - Pattern: {$match['pattern']}\n";
|
||||
$error_msg .= " Class: {$match['class']}::{$match['method']}\n";
|
||||
if (!empty($match['file'])) {
|
||||
$error_msg .= " File: {$match['file']}\n";
|
||||
if (!$has_route) {
|
||||
throw new \RuntimeException(
|
||||
"Route method {$class_fqcn}::{$method_name} is missing required #[Route] or #[SPA] attribute.\n" .
|
||||
"Pattern: {$pattern}\n" .
|
||||
"File: {$route['file']}"
|
||||
);
|
||||
}
|
||||
$error_msg .= " Type: {$match['type']}\n\n";
|
||||
}
|
||||
$error_msg .= 'Routes must be unique. Please remove duplicate route definitions.';
|
||||
|
||||
throw new RuntimeException($error_msg);
|
||||
// Return route with params
|
||||
return [
|
||||
'type' => $route['type'],
|
||||
'pattern' => $pattern,
|
||||
'class' => $route['class'],
|
||||
'method' => $route['method'],
|
||||
'params' => $params,
|
||||
'file' => $route['file'] ?? null,
|
||||
'require' => $route['require'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Return the single match or null
|
||||
return $matches[0] ?? null;
|
||||
// No match found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,8 +607,9 @@ class Dispatcher
|
||||
throw new Exception("Method not public: {$class_name}::{$method_name}");
|
||||
}
|
||||
|
||||
// Set current controller and action for tracking
|
||||
Rsx::_set_current_controller_action($class_name, $method_name, $params);
|
||||
// NOTE: Do NOT call _set_current_controller_action here - it's already been set
|
||||
// earlier in the dispatch flow with the correct route type. Calling it again
|
||||
// would overwrite the route type with null.
|
||||
|
||||
// Check if this is a controller (all methods are static)
|
||||
if (static::__is_controller($class_name)) {
|
||||
@@ -1096,7 +1132,11 @@ class Dispatcher
|
||||
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
|
||||
);
|
||||
}
|
||||
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index');
|
||||
$action = $redirect_to[0];
|
||||
if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') {
|
||||
$action .= '::' . $redirect_to[1];
|
||||
}
|
||||
$url = Rsx::Route($action);
|
||||
if ($message) {
|
||||
Rsx::flash_error($message);
|
||||
}
|
||||
|
||||
120
app/RSpade/Core/Dispatch/Route_ManifestSupport.php
Executable file
120
app/RSpade/Core/Dispatch/Route_ManifestSupport.php
Executable file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Dispatch;
|
||||
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for building routes index from #[Route] attributes
|
||||
* This runs after the primary manifest is built to create routes index
|
||||
*/
|
||||
class Route_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Routes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the manifest and build routes index
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize routes key
|
||||
if (!isset($manifest_data['data']['routes'])) {
|
||||
$manifest_data['data']['routes'] = [];
|
||||
}
|
||||
|
||||
// Look for Route attributes - must check all namespaces since Route is not a real class
|
||||
// PHP attributes without an import will use the current namespace
|
||||
$files = $manifest_data['data']['files'];
|
||||
$route_classes = [];
|
||||
|
||||
foreach ($files as $file => $metadata) {
|
||||
// Check public static method attributes for any attribute ending with 'Route'
|
||||
if (isset($metadata['public_static_methods'])) {
|
||||
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
|
||||
if (isset($method_data['attributes'])) {
|
||||
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
|
||||
// Check if this is a Route attribute (ends with \Route or is just Route)
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') {
|
||||
$route_classes[] = [
|
||||
'file' => $file,
|
||||
'class' => $metadata['class'] ?? null,
|
||||
'fqcn' => $metadata['fqcn'] ?? null,
|
||||
'method' => $method_name,
|
||||
'type' => 'method',
|
||||
'instances' => $attr_instances,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($route_classes as $item) {
|
||||
if ($item['type'] === 'method') {
|
||||
foreach ($item['instances'] as $route_args) {
|
||||
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
|
||||
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
|
||||
$name = $route_args[2] ?? ($route_args['name'] ?? null);
|
||||
|
||||
if ($pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($pattern[0] !== '/') {
|
||||
$pattern = '/' . $pattern;
|
||||
}
|
||||
|
||||
// Type is always 'standard' for routes with #[Route] attribute
|
||||
$type = 'standard';
|
||||
|
||||
// Extract Auth attributes for this method from the file metadata
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$item['file']] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'];
|
||||
}
|
||||
|
||||
// Check for duplicate route definition (pattern must be unique across all route types)
|
||||
if (isset($manifest_data['data']['routes'][$pattern])) {
|
||||
$existing = $manifest_data['data']['routes'][$pattern];
|
||||
$existing_type = $existing['type'];
|
||||
$existing_location = $existing_type === 'spa'
|
||||
? "SPA action {$existing['js_action_class']} in {$existing['file']}"
|
||||
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Duplicate route definition: {$pattern}\n" .
|
||||
" Already defined: {$existing_location}\n" .
|
||||
" Conflicting: {$item['fqcn']}::{$item['method']} in {$item['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Store route with flat structure
|
||||
$manifest_data['data']['routes'][$pattern] = [
|
||||
'methods' => array_map('strtoupper', (array) $methods),
|
||||
'type' => $type,
|
||||
'class' => $item['fqcn'] ?? $item['class'],
|
||||
'method' => $item['method'],
|
||||
'name' => $name,
|
||||
'file' => $item['file'],
|
||||
'require' => $require_attrs,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
|
||||
ksort($manifest_data['data']['routes']);
|
||||
}
|
||||
}
|
||||
229
app/RSpade/Core/Files/CLAUDE.md
Executable file
229
app/RSpade/Core/Files/CLAUDE.md
Executable file
@@ -0,0 +1,229 @@
|
||||
# File Attachments System
|
||||
|
||||
## Overview
|
||||
|
||||
The RSpade file attachment system provides secure, session-based file uploads with automatic thumbnail generation and polymorphic model associations.
|
||||
|
||||
## Upload Flow
|
||||
|
||||
**Security Model**: Files upload UNATTACHED → validate → assign via API
|
||||
|
||||
1. User uploads file to `/_upload` endpoint
|
||||
2. File saved with `session_id`, no model association
|
||||
3. Returns unique `key` to frontend
|
||||
4. Frontend calls API endpoint with key
|
||||
5. Backend validates ownership and assigns to model
|
||||
|
||||
## Security Implementation
|
||||
|
||||
**Session-based validation** prevents cross-user file assignment:
|
||||
- Files get `session_id` on upload
|
||||
- `can_user_assign_this_file()` validates:
|
||||
- File not already assigned
|
||||
- Same site_id as user's session
|
||||
- Same session_id (prevents cross-user assignment)
|
||||
- User-provided `fileable_*` params ignored during upload
|
||||
|
||||
## Attachment API
|
||||
|
||||
### File_Attachment_Model Methods
|
||||
|
||||
```php
|
||||
// Find attachment by key
|
||||
$attachment = File_Attachment_Model::find_by_key($key);
|
||||
|
||||
// Validate user can assign
|
||||
if ($attachment->can_user_assign_this_file()) {
|
||||
// Single file attachment (replaces existing)
|
||||
$attachment->attach_to($user, 'profile_photo');
|
||||
|
||||
// Multiple file attachment (adds to collection)
|
||||
$attachment->add_to($project, 'documents');
|
||||
}
|
||||
|
||||
// Remove assignment
|
||||
$attachment->detach();
|
||||
|
||||
// Check assignment status
|
||||
if ($attachment->is_attached()) {
|
||||
// File is assigned to a model
|
||||
}
|
||||
```
|
||||
|
||||
### Model Helper Methods
|
||||
|
||||
All models extending `Rsx_Model_Abstract` have attachment helpers:
|
||||
|
||||
```php
|
||||
// Get single attachment
|
||||
$photo = $user->get_attachment('profile_photo');
|
||||
|
||||
// Get multiple attachments
|
||||
$docs = $project->get_attachments('documents');
|
||||
|
||||
// Count attachments
|
||||
$count = $project->count_attachments('documents');
|
||||
|
||||
// Check if has attachments
|
||||
if ($user->has_attachment('profile_photo')) {
|
||||
// User has profile photo
|
||||
}
|
||||
```
|
||||
|
||||
## Display URLs
|
||||
|
||||
```php
|
||||
// Thumbnail with specific dimensions
|
||||
$photo->get_thumbnail_url('cover', 128, 128);
|
||||
|
||||
// Full file URL
|
||||
$photo->get_url();
|
||||
|
||||
// Force download URL
|
||||
$photo->get_download_url();
|
||||
|
||||
// Get file metadata
|
||||
$size = $photo->file_size;
|
||||
$mime = $photo->mime_type;
|
||||
$name = $photo->original_filename;
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `/_upload` - File upload endpoint
|
||||
- `/_download/:key` - Force download file
|
||||
- `/_thumbnail/:key/:type/:w/:h` - Generated thumbnail
|
||||
- `/_file/:key` - Direct file access
|
||||
|
||||
## Thumbnail System
|
||||
|
||||
Thumbnails are generated on-demand and cached:
|
||||
|
||||
**Preset types** (defined in config):
|
||||
- `cover` - Cover image aspect ratio
|
||||
- `square` - 1:1 aspect ratio
|
||||
- `landscape` - 16:9 aspect ratio
|
||||
|
||||
**Dynamic thumbnails**:
|
||||
- Limited to `max_dynamic_size` (default 2000px)
|
||||
- Cached for performance
|
||||
- Automatic cleanup via scheduled task
|
||||
|
||||
## Controller Implementation Pattern
|
||||
|
||||
```php
|
||||
#[Ajax_Endpoint]
|
||||
public static function save_with_photo(Request $request, array $params = [])
|
||||
{
|
||||
// Validate required fields
|
||||
if (empty($params['name'])) {
|
||||
return response_form_error('Validation failed', [
|
||||
'name' => 'Name is required'
|
||||
]);
|
||||
}
|
||||
|
||||
// Save model
|
||||
$user = new User_Model();
|
||||
$user->name = $params['name'];
|
||||
$user->save();
|
||||
|
||||
// Attach photo if provided
|
||||
if (!empty($params['photo_key'])) {
|
||||
$photo = File_Attachment_Model::find_by_key($params['photo_key']);
|
||||
|
||||
if (!$photo || !$photo->can_user_assign_this_file()) {
|
||||
return response_form_error('Invalid file', [
|
||||
'photo' => 'File not found or access denied'
|
||||
]);
|
||||
}
|
||||
|
||||
$photo->attach_to($user, 'profile_photo');
|
||||
}
|
||||
|
||||
return ['user_id' => $user->id];
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Upload Component
|
||||
|
||||
```javascript
|
||||
// Using Rsx_File_Upload component
|
||||
<Rsx_File_Upload
|
||||
$id="photo_upload"
|
||||
$accept="image/*"
|
||||
$max_size="5242880"
|
||||
/>
|
||||
|
||||
// Get uploaded file key
|
||||
const key = this.$id('photo_upload').component().get_file_key();
|
||||
|
||||
// Submit with form
|
||||
const data = {
|
||||
name: this.$id('name').val(),
|
||||
photo_key: key
|
||||
};
|
||||
await Controller.save_with_photo(data);
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
file_attachments
|
||||
├── id (bigint)
|
||||
├── key (varchar 64, unique)
|
||||
├── site_id (bigint)
|
||||
├── session_id (bigint)
|
||||
├── fileable_type (varchar 255, nullable)
|
||||
├── fileable_id (bigint, nullable)
|
||||
├── fileable_key (varchar 255, nullable)
|
||||
├── storage_path (varchar 500)
|
||||
├── original_filename (varchar 500)
|
||||
├── mime_type (varchar 255)
|
||||
├── file_size (bigint)
|
||||
├── metadata (json)
|
||||
└── timestamps
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
In `/system/config/rsx.php`:
|
||||
|
||||
```php
|
||||
'attachments' => [
|
||||
'upload_dir' => storage_path('rsx-attachments'),
|
||||
'max_upload_size' => 10 * 1024 * 1024, // 10MB
|
||||
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx'],
|
||||
],
|
||||
|
||||
'thumbnails' => [
|
||||
'presets' => [
|
||||
'cover' => ['width' => 800, 'height' => 600],
|
||||
'square' => ['width' => 300, 'height' => 300],
|
||||
],
|
||||
'max_dynamic_size' => 2000,
|
||||
'quotas' => [
|
||||
'preset_max_bytes' => 500 * 1024 * 1024, // 500MB
|
||||
'dynamic_max_bytes' => 100 * 1024 * 1024, // 100MB
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Never trust client-provided fileable_* params** during upload
|
||||
2. **Always validate ownership** before assignment
|
||||
3. **Use polymorphic associations** for flexibility
|
||||
4. **Implement access control** in download endpoints
|
||||
5. **Sanitize filenames** to prevent directory traversal
|
||||
6. **Validate MIME types** server-side
|
||||
7. **Set appropriate upload size limits**
|
||||
8. **Use scheduled cleanup** for orphaned files
|
||||
|
||||
## Scheduled Cleanup
|
||||
|
||||
Orphaned files (uploaded but never assigned) are cleaned automatically:
|
||||
- Files older than 24 hours without assignment
|
||||
- Runs daily via scheduled task
|
||||
- Preserves actively used files
|
||||
|
||||
See also: `php artisan rsx:man file_upload`
|
||||
@@ -418,7 +418,146 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
// ============================================================================================
|
||||
|
||||
/**
|
||||
* Generate thumbnail for image attachments
|
||||
* Generate and serve thumbnail with caching
|
||||
*
|
||||
* Common logic for both preset and dynamic thumbnails.
|
||||
*
|
||||
* @param File_Attachment_Model $attachment
|
||||
* @param string $type Thumbnail type: 'cover' or 'fit'
|
||||
* @param int $width Width in pixels
|
||||
* @param int|null $height Height in pixels
|
||||
* @param string $cache_type Either 'preset' or 'dynamic'
|
||||
* @param string $cache_filename Cache filename
|
||||
* @return Response
|
||||
*/
|
||||
protected static function __generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
$cache_type,
|
||||
$cache_filename
|
||||
) {
|
||||
// Get storage file
|
||||
$storage = $attachment->file_storage;
|
||||
if (!$storage || !file_exists($storage->get_full_path())) {
|
||||
abort(404, 'File not found on disk');
|
||||
}
|
||||
|
||||
// Build cache path
|
||||
$cache_path = static::_get_cache_path($cache_type, $cache_filename);
|
||||
|
||||
// Check cache
|
||||
if (file_exists($cache_path)) {
|
||||
$response = static::_serve_cached_thumbnail($cache_path);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
// If null, file was deleted (race condition) - fall through to regeneration
|
||||
}
|
||||
|
||||
// Generate thumbnail
|
||||
if ($attachment->is_image()) {
|
||||
$thumbnail_data = static::__generate_thumbnail(
|
||||
$storage->get_full_path(),
|
||||
$type,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
} else {
|
||||
// Use icon-based thumbnail for non-images
|
||||
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
|
||||
$attachment->file_extension,
|
||||
$width,
|
||||
$height ?? $width
|
||||
);
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
static::_save_thumbnail_to_cache($cache_path, $thumbnail_data);
|
||||
|
||||
// Enforce quota only for dynamic thumbnails
|
||||
if ($cache_type === 'dynamic') {
|
||||
static::_enforce_dynamic_quota();
|
||||
}
|
||||
|
||||
// Return thumbnail (serve from memory, don't re-read from disk)
|
||||
return Response::make($thumbnail_data, 200, [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'public, max-age=31536000', // 1 year
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preset thumbnail for image attachments
|
||||
*
|
||||
* Route: /_thumbnail/:key/preset/:preset_name
|
||||
*
|
||||
* Security: Checks file.thumbnail.authorize only
|
||||
*
|
||||
* @param string $key Attachment key
|
||||
* @param string $preset_name Preset name from config
|
||||
*/
|
||||
#[Route('/_thumbnail/:key/preset/:preset_name', methods: ['GET'])]
|
||||
#[Auth('Permission::anybody()')]
|
||||
public static function thumbnail_preset(Request $request, array $params = [])
|
||||
{
|
||||
$key = $params['key'] ?? null;
|
||||
$preset_name = $params['preset_name'] ?? null;
|
||||
|
||||
// Validate inputs
|
||||
if (!$key || !$preset_name) {
|
||||
abort(400, 'Invalid parameters');
|
||||
}
|
||||
|
||||
// Look up preset definition
|
||||
$presets = config('rsx.thumbnails.presets', []);
|
||||
if (!isset($presets[$preset_name])) {
|
||||
abort(404, "Thumbnail preset '{$preset_name}' not defined");
|
||||
}
|
||||
|
||||
$preset = $presets[$preset_name];
|
||||
$type = $preset['type'];
|
||||
$width = $preset['width'];
|
||||
$height = $preset['height'] ?? null;
|
||||
|
||||
// Find attachment
|
||||
$attachment = File_Attachment_Model::where('key', $key)->first();
|
||||
if (!$attachment) {
|
||||
abort(404, 'File not found');
|
||||
}
|
||||
|
||||
// Event: file.thumbnail.authorize (gate) - Check thumbnail access
|
||||
$thumbnail_auth = Rsx::trigger_gate('file.thumbnail.authorize', [
|
||||
'attachment' => $attachment,
|
||||
'user' => Session::get_user(),
|
||||
'request' => $request,
|
||||
]);
|
||||
|
||||
if ($thumbnail_auth !== true) {
|
||||
return $thumbnail_auth;
|
||||
}
|
||||
|
||||
// Generate cache filename
|
||||
$cache_filename = static::_get_cache_filename_preset(
|
||||
$preset_name,
|
||||
$attachment->file_storage->hash,
|
||||
$attachment->file_extension
|
||||
);
|
||||
|
||||
// Generate and serve
|
||||
return static::__generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
'preset',
|
||||
$cache_filename
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dynamic thumbnail for image attachments
|
||||
*
|
||||
* Route: /_thumbnail/:key/:type/:width/:height?
|
||||
*
|
||||
@@ -451,13 +590,14 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
$height = 10;
|
||||
}
|
||||
|
||||
// Enforce maximum dimensions
|
||||
if ($width > 256) {
|
||||
abort(400, 'Width must be between 10 and 256');
|
||||
// Enforce maximum dimensions (configurable, base resolution before 2x scaling)
|
||||
$max_size = config('rsx.thumbnails.max_dynamic_size', 800);
|
||||
if ($width > $max_size) {
|
||||
abort(400, "Width must be between 10 and {$max_size}");
|
||||
}
|
||||
|
||||
if ($height !== null && $height > 256) {
|
||||
abort(400, 'Height must be between 10 and 256');
|
||||
if ($height !== null && $height > $max_size) {
|
||||
abort(400, "Height must be between 10 and {$max_size}");
|
||||
}
|
||||
|
||||
// Find attachment
|
||||
@@ -477,42 +617,33 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
return $thumbnail_auth;
|
||||
}
|
||||
|
||||
// Get storage file
|
||||
$storage = $attachment->file_storage;
|
||||
if (!$storage || !file_exists($storage->get_full_path())) {
|
||||
abort(404, 'File not found on disk');
|
||||
}
|
||||
// Generate cache filename
|
||||
$cache_filename = static::_get_cache_filename_dynamic(
|
||||
$type,
|
||||
$width,
|
||||
$height ?? $width,
|
||||
$attachment->file_storage->hash,
|
||||
$attachment->file_extension
|
||||
);
|
||||
|
||||
// Try to generate thumbnail from actual file if possible
|
||||
if ($attachment->is_image()) {
|
||||
// Generate thumbnail from image
|
||||
$thumbnail_data = static::__generate_thumbnail(
|
||||
$storage->get_full_path(),
|
||||
$type,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
} else {
|
||||
// No source image or converter available - use icon-based thumbnail
|
||||
// NOTE: Not caching here because caching will be implemented at the
|
||||
// thumbnail storage level to allow deduplication across files of the same type
|
||||
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
|
||||
$attachment->file_extension,
|
||||
$width,
|
||||
$height ?? $width
|
||||
);
|
||||
}
|
||||
|
||||
// Return thumbnail
|
||||
return Response::make($thumbnail_data, 200, [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'public, max-age=31536000', // 1 year
|
||||
]);
|
||||
// Generate and serve
|
||||
return static::__generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
'dynamic',
|
||||
$cache_filename
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail image using Imagick
|
||||
*
|
||||
* Applies 2x resolution scaling for HiDPI displays, but caps at 66% of source
|
||||
* image dimensions to avoid excessive upscaling. Output aspect ratio always
|
||||
* matches requested dimensions, but actual resolution may be lower.
|
||||
*
|
||||
* @param string $source_path Source file path
|
||||
* @param string $type Thumbnail type: 'cover' or 'fit'
|
||||
* @param int $width Target width
|
||||
@@ -537,17 +668,37 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
$height = (int)round(($width / $original_width) * $original_height);
|
||||
}
|
||||
|
||||
// Constrain to 256x256 max
|
||||
if ($width > 256 || $height > 256) {
|
||||
if ($width > $height) {
|
||||
$height = (int)round(($height / $width) * 256);
|
||||
$width = 256;
|
||||
// Apply 2x scaling for HiDPI displays
|
||||
$target_width = $width * 2;
|
||||
$target_height = $height * 2;
|
||||
|
||||
// Calculate 66% threshold of source dimensions
|
||||
$max_width = (int)round($original_width * 0.66);
|
||||
$max_height = (int)round($original_height * 0.66);
|
||||
|
||||
// If target exceeds 66% of source on either dimension, cap at source dimensions
|
||||
if ($target_width > $max_width || $target_height > $max_height) {
|
||||
$target_width = $original_width;
|
||||
$target_height = $original_height;
|
||||
}
|
||||
|
||||
// Constrain to configured max (doubled for 2x scaling)
|
||||
// Default: 800 base → 1600 max after 2x
|
||||
$max_size = config('rsx.thumbnails.max_dynamic_size', 800) * 2;
|
||||
if ($target_width > $max_size || $target_height > $max_size) {
|
||||
if ($target_width > $target_height) {
|
||||
$target_height = (int)round(($target_height / $target_width) * $max_size);
|
||||
$target_width = $max_size;
|
||||
} else {
|
||||
$width = (int)round(($width / $height) * 256);
|
||||
$height = 256;
|
||||
$target_width = (int)round(($target_width / $target_height) * $max_size);
|
||||
$target_height = $max_size;
|
||||
}
|
||||
}
|
||||
|
||||
// Use target dimensions for actual generation
|
||||
$width = $target_width;
|
||||
$height = $target_height;
|
||||
|
||||
if ($type === 'cover') {
|
||||
// Cover: Fill area completely, crop excess
|
||||
// Calculate aspect ratios
|
||||
@@ -614,6 +765,175 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
return $thumbnail_data;
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// THUMBNAIL CACHING HELPERS
|
||||
// ============================================================================================
|
||||
|
||||
/**
|
||||
* Generate cache filename for preset thumbnail
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Format: {preset_name}_{hash}_{ext}.webp
|
||||
*
|
||||
* @param string $preset_name Preset name from config
|
||||
* @param string $hash File storage hash
|
||||
* @param string $extension File extension (normalized)
|
||||
* @return string Cache filename
|
||||
*/
|
||||
public static function _get_cache_filename_preset($preset_name, $hash, $extension)
|
||||
{
|
||||
return "{$preset_name}_{$hash}_{$extension}.webp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache filename for dynamic thumbnail
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Format: {type}_{width}x{height}_{hash}_{ext}.webp
|
||||
*
|
||||
* @param string $type Thumbnail type (cover or fit)
|
||||
* @param int $width Width in pixels
|
||||
* @param int $height Height in pixels
|
||||
* @param string $hash File storage hash
|
||||
* @param string $extension File extension (normalized)
|
||||
* @return string Cache filename
|
||||
*/
|
||||
public static function _get_cache_filename_dynamic($type, $width, $height, $hash, $extension)
|
||||
{
|
||||
return "{$type}_{$width}x{$height}_{$hash}_{$extension}.webp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full cache path for thumbnail
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* @param string $cache_type Either 'preset' or 'dynamic'
|
||||
* @param string $filename Cache filename
|
||||
* @return string Full filesystem path
|
||||
*/
|
||||
public static function _get_cache_path($cache_type, $filename)
|
||||
{
|
||||
return storage_path("rsx-thumbnails/{$cache_type}/{$filename}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve cached thumbnail from disk with optional mtime touch
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Handles race condition where file might be deleted between check and open.
|
||||
* Returns null if file cannot be opened (caller should regenerate).
|
||||
*
|
||||
* @param string $cache_path Full path to cached file
|
||||
* @return Response|null Response if successful, null if file unavailable
|
||||
*/
|
||||
protected static function _serve_cached_thumbnail($cache_path)
|
||||
{
|
||||
// Attempt to open file
|
||||
$handle = @fopen($cache_path, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
// File was deleted between exists check and open (race condition)
|
||||
return null;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
// Touch mtime if configured and old enough
|
||||
$touch_enabled = config('rsx.thumbnails.touch_on_read', true);
|
||||
$touch_interval = config('rsx.thumbnails.touch_interval', 600);
|
||||
|
||||
if ($touch_enabled && $touch_interval > 0) {
|
||||
$mtime = filemtime($cache_path);
|
||||
$age = time() - $mtime;
|
||||
|
||||
if ($age >= $touch_interval) {
|
||||
touch($cache_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Serve file
|
||||
return Response::file($cache_path, [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'public, max-age=31536000', // 1 year
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save generated thumbnail to cache
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Creates directory if it doesn't exist.
|
||||
*
|
||||
* @param string $cache_path Full path where thumbnail should be saved
|
||||
* @param string $thumbnail_data Binary WebP data
|
||||
* @return void
|
||||
*/
|
||||
public static function _save_thumbnail_to_cache($cache_path, $thumbnail_data)
|
||||
{
|
||||
// Ensure directory exists
|
||||
$dir = dirname($cache_path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
// Save thumbnail
|
||||
file_put_contents($cache_path, $thumbnail_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce quota for dynamic thumbnails
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Scans dynamic thumbnail directory, calculates total size, and deletes
|
||||
* oldest files (by mtime) until under quota limit.
|
||||
*
|
||||
* Called synchronously after creating each new dynamic thumbnail.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function _enforce_dynamic_quota()
|
||||
{
|
||||
$max_bytes = config('rsx.thumbnails.quotas.dynamic_max_bytes');
|
||||
$dir = storage_path('rsx-thumbnails/dynamic/');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate current usage
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Over quota? Delete oldest first
|
||||
if ($total_size > $max_bytes) {
|
||||
// Sort by mtime ascending (oldest first)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
|
||||
if ($total_size <= $max_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// ICON UTILITIES
|
||||
// ============================================================================================
|
||||
|
||||
@@ -149,7 +149,7 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'file_attachments';
|
||||
protected $table = '_file_attachments';
|
||||
|
||||
/**
|
||||
* Get the physical file storage record
|
||||
@@ -246,7 +246,7 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail URL for this file
|
||||
* Get thumbnail URL for this file (dynamic thumbnails)
|
||||
*
|
||||
* @param string $type Thumbnail type: 'cover' or 'fit'
|
||||
* @param int $width Thumbnail width in pixels
|
||||
@@ -261,6 +261,27 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
return url("/_thumbnail/{$this->key}/{$type}/{$width}/{$height}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail URL for named preset
|
||||
*
|
||||
* Returns URL to preset thumbnail endpoint. Preset must be defined in
|
||||
* config/rsx.php under 'thumbnails.presets'.
|
||||
*
|
||||
* @param string $preset_name Preset name from config (e.g., 'profile', 'gallery')
|
||||
* @return string URL to thumbnail endpoint
|
||||
* @throws Exception if preset not defined
|
||||
*/
|
||||
public function get_thumbnail_url_preset($preset_name)
|
||||
{
|
||||
$presets = config('rsx.thumbnails.presets', []);
|
||||
|
||||
if (!isset($presets[$preset_name])) {
|
||||
throw new Exception("Thumbnail preset '{$preset_name}' not defined in config");
|
||||
}
|
||||
|
||||
return url("/_thumbnail/{$this->key}/preset/{$preset_name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon resource path for this file type
|
||||
*
|
||||
|
||||
@@ -40,7 +40,7 @@ class File_Storage_Model extends Rsx_Model_Abstract
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'file_storage';
|
||||
protected $table = '_file_storage';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Files;
|
||||
|
||||
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
use App\RSpade\Core\Files\File_Storage_Model;
|
||||
|
||||
/**
|
||||
* File_Thumbnail_Model - Thumbnail cache with deduplication
|
||||
*
|
||||
* Represents generated thumbnails as derivative artifacts of source files.
|
||||
* Thumbnails are deduplicated based on source file hash + parameters + mime type.
|
||||
*
|
||||
* Multiple File_Attachment_Model records sharing the same File_Storage_Model will
|
||||
* share the same thumbnail if they request the same thumbnail parameters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_ Database type hints - do not edit manually
|
||||
* Generated on: 2025-11-04 07:18:11
|
||||
* Table: file_thumbnails
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $source_storage_id
|
||||
* @property int $thumbnail_storage_id
|
||||
* @property string $params
|
||||
* @property mixed $detected_mime_type
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class File_Thumbnail_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
// Required static properties from parent abstract class
|
||||
public static $enums = [];
|
||||
public static $rel = [];
|
||||
|
||||
/**
|
||||
* The table associated with the model
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'file_thumbnails';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Column metadata for special handling
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $columnMeta = [];
|
||||
|
||||
/**
|
||||
* Get the source file storage record
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function source_storage()
|
||||
{
|
||||
return $this->belongsTo(File_Storage_Model::class, 'source_storage_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail file storage record
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function thumbnail_storage()
|
||||
{
|
||||
return $this->belongsTo(File_Storage_Model::class, 'thumbnail_storage_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decoded params JSON
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_params()
|
||||
{
|
||||
return json_decode($this->params, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate thumbnail cache key
|
||||
*
|
||||
* This key determines thumbnail deduplication. Thumbnails with the same
|
||||
* source file, parameters, and detected mime type will share the same
|
||||
* thumbnail storage.
|
||||
*
|
||||
* Formula: SHA-256(source_hash + params_json + detected_mime_type)
|
||||
*
|
||||
* @param string $source_hash The hash of the source File_Storage_Model
|
||||
* @param array $params Thumbnail parameters (width, height, crop, format, quality)
|
||||
* @param string $detected_mime Actual mime type detected from file content
|
||||
* @return string SHA-256 hash for thumbnail lookup
|
||||
*/
|
||||
public static function calculate_thumbnail_key($source_hash, array $params, $detected_mime)
|
||||
{
|
||||
// Normalize params to ensure consistent key generation
|
||||
ksort($params);
|
||||
$params_json = json_encode($params);
|
||||
|
||||
// Combine all factors that determine thumbnail uniqueness
|
||||
$key_string = $source_hash . $params_json . $detected_mime;
|
||||
|
||||
return hash('sha256', $key_string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing thumbnail or return null
|
||||
*
|
||||
* @param int $source_storage_id
|
||||
* @param array $params
|
||||
* @param string $detected_mime
|
||||
* @return static|null
|
||||
*/
|
||||
public static function find_thumbnail($source_storage_id, array $params, $detected_mime)
|
||||
{
|
||||
$params_json = json_encode($params);
|
||||
|
||||
return static::where('source_storage_id', $source_storage_id)
|
||||
->where('params', $params_json)
|
||||
->where('detected_mime_type', $detected_mime)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or find a thumbnail record
|
||||
*
|
||||
* @param File_Storage_Model $source_storage
|
||||
* @param File_Storage_Model $thumbnail_storage
|
||||
* @param array $params
|
||||
* @param string $detected_mime
|
||||
* @return static
|
||||
*/
|
||||
public static function create_thumbnail($source_storage, $thumbnail_storage, array $params, $detected_mime)
|
||||
{
|
||||
$params_json = json_encode($params);
|
||||
|
||||
$thumbnail = new static();
|
||||
$thumbnail->source_storage_id = $source_storage->id;
|
||||
$thumbnail->thumbnail_storage_id = $thumbnail_storage->id;
|
||||
$thumbnail->params = $params_json;
|
||||
$thumbnail->detected_mime_type = $detected_mime;
|
||||
$thumbnail->save();
|
||||
|
||||
return $thumbnail;
|
||||
}
|
||||
}
|
||||
252
app/RSpade/Core/Files/File_Thumbnail_Service.php
Executable file
252
app/RSpade/Core/Files/File_Thumbnail_Service.php
Executable file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Files;
|
||||
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
/**
|
||||
* File_Thumbnail_Service
|
||||
*
|
||||
* Business logic for thumbnail cache management including:
|
||||
* - Quota enforcement via LRU eviction
|
||||
* - Scheduled cleanup of preset thumbnails
|
||||
* - Statistics and reporting
|
||||
*
|
||||
* Storage Structure:
|
||||
* - storage/rsx-thumbnails/preset/ - Named preset thumbnails (100MB quota, scheduled cleanup)
|
||||
* - storage/rsx-thumbnails/dynamic/ - Dynamic ad-hoc thumbnails (50MB quota, synchronous cleanup)
|
||||
*
|
||||
* Cleanup Strategy:
|
||||
* - Preset: Runs every 30 minutes via scheduled task (this class)
|
||||
* - Dynamic: Enforced synchronously in File_Attachment_Controller after each generation
|
||||
* - Both: LRU eviction (oldest mtime deleted first)
|
||||
*/
|
||||
class File_Thumbnail_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
/**
|
||||
* Scheduled cleanup of preset thumbnail cache
|
||||
*
|
||||
* Runs every 30 minutes to enforce preset thumbnail quota.
|
||||
* Deletes oldest files (by mtime) until under configured limit.
|
||||
*
|
||||
* @param Task_Instance $task Task instance for logging
|
||||
* @param array $params Task parameters
|
||||
*/
|
||||
#[Task('Clean preset thumbnail cache (runs every 30 minutes)')]
|
||||
#[Schedule('*/30 * * * *')]
|
||||
public static function cleanup_preset_thumbnails(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$max_bytes = config('rsx.thumbnails.quotas.preset_max_bytes');
|
||||
$dir = storage_path('rsx-thumbnails/preset/');
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
// Directory doesn't exist yet - no cleanup needed
|
||||
$task->info('Preset thumbnail directory does not exist yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate current usage
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Not over quota? Nothing to do
|
||||
if ($total_size <= $max_bytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Over quota - delete oldest files until under limit
|
||||
// Sort by mtime ascending (oldest first)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
$deleted_count = 0;
|
||||
$freed_bytes = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
$deleted_count++;
|
||||
$freed_bytes += $file['size'];
|
||||
|
||||
if ($total_size <= $max_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Log cleanup statistics
|
||||
$freed_mb = round($freed_bytes / 1024 / 1024, 2);
|
||||
$task->info("Preset thumbnail cleanup: {$deleted_count} files deleted, {$freed_mb} MB freed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a thumbnail directory by enforcing quota
|
||||
*
|
||||
* Used by CLI commands for manual cleanup operations.
|
||||
*
|
||||
* @param string $type Either 'preset' or 'dynamic'
|
||||
* @return array [files_deleted, bytes_freed]
|
||||
*/
|
||||
public static function clean_directory($type)
|
||||
{
|
||||
$quota_key = $type . '_max_bytes';
|
||||
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
|
||||
$dir = storage_path("rsx-thumbnails/{$type}/");
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
// Calculate current usage
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Not over quota? Nothing to do
|
||||
if ($total_size <= $max_bytes) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
// Over quota - delete oldest first (LRU eviction)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
$deleted_count = 0;
|
||||
$freed_bytes = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
$deleted_count++;
|
||||
$freed_bytes += $file['size'];
|
||||
|
||||
if ($total_size <= $max_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [$deleted_count, $freed_bytes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for thumbnail cache directories
|
||||
*
|
||||
* @return array Statistics for preset and dynamic directories
|
||||
*/
|
||||
public static function get_statistics()
|
||||
{
|
||||
$stats = [
|
||||
'preset' => static::__get_directory_stats('preset'),
|
||||
'dynamic' => static::__get_directory_stats('dynamic'),
|
||||
];
|
||||
|
||||
// Add breakdown by preset name
|
||||
$stats['preset_breakdown'] = static::__get_preset_breakdown();
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a single directory
|
||||
*
|
||||
* @param string $type Either 'preset' or 'dynamic'
|
||||
* @return array Statistics including file count, total size, oldest/newest
|
||||
*/
|
||||
protected static function __get_directory_stats($type)
|
||||
{
|
||||
$dir = storage_path("rsx-thumbnails/{$type}/");
|
||||
$quota_key = $type . '_max_bytes';
|
||||
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [
|
||||
'exists' => false,
|
||||
'file_count' => 0,
|
||||
'total_bytes' => 0,
|
||||
'max_bytes' => $max_bytes,
|
||||
'usage_percent' => 0,
|
||||
'oldest' => null,
|
||||
'newest' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$files = glob($dir . '*.webp');
|
||||
$file_count = count($files);
|
||||
$total_bytes = 0;
|
||||
$oldest = null;
|
||||
$newest = null;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$total_bytes += filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
|
||||
if ($oldest === null || $mtime < $oldest) {
|
||||
$oldest = $mtime;
|
||||
}
|
||||
if ($newest === null || $mtime > $newest) {
|
||||
$newest = $mtime;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'exists' => true,
|
||||
'file_count' => $file_count,
|
||||
'total_bytes' => $total_bytes,
|
||||
'max_bytes' => $max_bytes,
|
||||
'usage_percent' => $max_bytes > 0 ? round(($total_bytes / $max_bytes) * 100, 1) : 0,
|
||||
'oldest' => $oldest,
|
||||
'newest' => $newest,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breakdown of preset thumbnails by preset name
|
||||
*
|
||||
* @return array Breakdown by preset name
|
||||
*/
|
||||
protected static function __get_preset_breakdown()
|
||||
{
|
||||
$dir = storage_path('rsx-thumbnails/preset/');
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$presets = config('rsx.thumbnails.presets', []);
|
||||
$breakdown = [];
|
||||
|
||||
// Initialize breakdown for each preset
|
||||
foreach (array_keys($presets) as $preset_name) {
|
||||
$breakdown[$preset_name] = [
|
||||
'file_count' => 0,
|
||||
'total_bytes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Count files for each preset
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$filename = basename($file);
|
||||
// Extract preset name from filename (format: presetname_hash_ext.webp)
|
||||
if (preg_match('/^([^_]+)_/', $filename, $matches)) {
|
||||
$preset_name = $matches[1];
|
||||
if (isset($breakdown[$preset_name])) {
|
||||
$breakdown[$preset_name]['file_count']++;
|
||||
$breakdown[$preset_name]['total_bytes'] += filesize($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
}
|
||||
364
app/RSpade/Core/Files/THUMBNAILS.md
Executable file
364
app/RSpade/Core/Files/THUMBNAILS.md
Executable file
@@ -0,0 +1,364 @@
|
||||
# Thumbnail Caching System - Developer Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The RSX thumbnail caching system provides a two-tier architecture designed to prevent cache pollution while maintaining flexibility. This document explains the implementation details for framework developers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two-Tier System
|
||||
|
||||
**Preset Thumbnails** (`storage/rsx-thumbnails/preset/`)
|
||||
- Developer-defined named sizes in config
|
||||
- Quota enforced via scheduled task (`rsx:thumbnails:clean --preset`)
|
||||
- Used for regular application features
|
||||
- Protected from abuse/spam
|
||||
|
||||
**Dynamic Thumbnails** (`storage/rsx-thumbnails/dynamic/`)
|
||||
- Ad-hoc sizes via URL parameters
|
||||
- Quota enforced synchronously after each generation
|
||||
- Used for edge cases and development
|
||||
- LRU eviction prevents unbounded growth
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
**Problem**: Traditional on-demand thumbnail generation allows users to request arbitrary sizes (e.g., by manipulating URL parameters), potentially generating thousands of cached thumbnails and consuming disk space.
|
||||
|
||||
**Solution**:
|
||||
1. Define all application thumbnail sizes as named presets
|
||||
2. Reference presets by name (`get_thumbnail_url_preset('profile')`)
|
||||
3. Dynamic thumbnails still available but discouraged and quota-limited
|
||||
|
||||
**Benefits**:
|
||||
- Prevents cache pollution from URL manipulation
|
||||
- Centralizes thumbnail size definitions
|
||||
- Enables cache warming via `rsx:thumbnails:generate`
|
||||
- Predictable cache size and cleanup
|
||||
|
||||
## Storage Structure
|
||||
|
||||
### Filename Format
|
||||
|
||||
**Preset**: `{preset_name}_{storage_hash}_{extension}.webp`
|
||||
- Example: `profile_abc123def456_jpg.webp`
|
||||
|
||||
**Dynamic**: `{type}_{width}x{height}_{storage_hash}_{extension}.webp`
|
||||
- Example: `cover_200x200_abc123def456_jpg.webp`
|
||||
|
||||
### Why Include Extension?
|
||||
|
||||
Edge case: Identical file content uploaded with different extensions (e.g., `document.zip` and `document.docx`) should render different icon-based thumbnails. Including the extension in the filename ensures separate caches.
|
||||
|
||||
## Caching Flow
|
||||
|
||||
### Request Processing
|
||||
|
||||
**Route**: `/_thumbnail/:key/:type/:width/:height` (dynamic) or `/_thumbnail/:key/preset/:preset_name` (preset)
|
||||
|
||||
**Common Flow** (via `generate_and_serve_thumbnail()`):
|
||||
1. Validate authorization (`file.thumbnail.authorize` event)
|
||||
2. Generate cache filename
|
||||
3. Check if cached file exists
|
||||
4. If exists:
|
||||
- Attempt to open (handles race condition)
|
||||
- If successful: touch mtime (if enabled), serve from disk
|
||||
- If failed: fall through to regeneration
|
||||
5. If not exists:
|
||||
- Generate thumbnail (Imagick for images, icon-based for others)
|
||||
- Save to cache
|
||||
- Enforce quota (dynamic only)
|
||||
- Serve from memory (no re-read)
|
||||
|
||||
### Race Condition Handling
|
||||
|
||||
**Scenario**: Between `file_exists()` check and `fopen()`, file might be deleted by quota enforcement in another request.
|
||||
|
||||
**Solution**: `_serve_cached_thumbnail()` returns `null` if file cannot be opened, triggering immediate regeneration. User never sees an error.
|
||||
|
||||
```php
|
||||
if (file_exists($cache_path)) {
|
||||
$response = static::_serve_cached_thumbnail($cache_path);
|
||||
if ($response !== null) {
|
||||
return $response; // Success
|
||||
}
|
||||
// File deleted - fall through to regeneration
|
||||
}
|
||||
|
||||
// Generate and serve
|
||||
$thumbnail_data = static::__generate_thumbnail(...);
|
||||
```
|
||||
|
||||
### LRU Tracking
|
||||
|
||||
**mtime Touching**:
|
||||
- On cache hit, touch file's mtime if older than `touch_interval` (default 10 minutes)
|
||||
- Prevents excessive filesystem writes while maintaining LRU accuracy
|
||||
- Used by quota enforcement to delete oldest (least recently used) files first
|
||||
|
||||
**Configuration**:
|
||||
```php
|
||||
'touch_on_read' => true, // Enable/disable
|
||||
'touch_interval' => 600, // Seconds (10 minutes)
|
||||
```
|
||||
|
||||
## Quota Enforcement
|
||||
|
||||
### Dynamic Thumbnails (Synchronous)
|
||||
|
||||
Called after each new dynamic thumbnail generation:
|
||||
|
||||
```php
|
||||
protected static function _enforce_dynamic_quota()
|
||||
{
|
||||
$max_bytes = config('rsx.thumbnails.quotas.dynamic_max_bytes');
|
||||
$dir = storage_path('rsx-thumbnails/dynamic/');
|
||||
|
||||
// Scan directory
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Over quota?
|
||||
if ($total_size > $max_bytes) {
|
||||
// Sort by mtime (oldest first)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
// Delete oldest until under quota
|
||||
foreach ($files as $file) {
|
||||
unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
if ($total_size <= $max_bytes) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Performance**: Glob + stat operations are fast for directories with <10,000 files. Dynamic quota prevents unbounded growth.
|
||||
|
||||
### Preset Thumbnails (Scheduled)
|
||||
|
||||
Not enforced synchronously. Run via scheduled task:
|
||||
```bash
|
||||
php artisan rsx:thumbnails:clean --preset
|
||||
```
|
||||
|
||||
Same LRU algorithm as dynamic, but triggered externally.
|
||||
|
||||
## Helper Methods
|
||||
|
||||
### Internal Helpers (Underscore-Prefixed)
|
||||
|
||||
These are `public static` for artisan command access but prefixed with `_` to indicate they're internal:
|
||||
|
||||
**`_get_cache_filename_preset($preset_name, $hash, $extension)`**
|
||||
- Generates: `{preset_name}_{hash}_{ext}.webp`
|
||||
|
||||
**`_get_cache_filename_dynamic($type, $width, $height, $hash, $extension)`**
|
||||
- Generates: `{type}_{w}x{h}_{hash}_{ext}.webp`
|
||||
|
||||
**`_get_cache_path($cache_type, $filename)`**
|
||||
- Returns: `storage_path("rsx-thumbnails/{$cache_type}/{$filename}")`
|
||||
|
||||
**`_save_thumbnail_to_cache($cache_path, $thumbnail_data)`**
|
||||
- Writes WebP data to cache
|
||||
- Creates directory if needed
|
||||
|
||||
**`_serve_cached_thumbnail($cache_path)`**
|
||||
- Attempts to open file (race condition safe)
|
||||
- Touches mtime if configured
|
||||
- Returns Response or null
|
||||
|
||||
**`_enforce_dynamic_quota()`**
|
||||
- Scans dynamic directory
|
||||
- Deletes oldest files until under quota
|
||||
|
||||
### Why Public?
|
||||
|
||||
Artisan commands (`Thumbnails_Generate_Command`, etc.) need access to these helpers. Making them public with `_` prefix indicates "internal use only - don't call from application code."
|
||||
|
||||
## DRY Implementation
|
||||
|
||||
**Single Generation Function**:
|
||||
```php
|
||||
protected static function generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
$cache_type,
|
||||
$cache_filename
|
||||
) {
|
||||
// Common logic for both preset and dynamic
|
||||
// Check cache -> Generate if needed -> Save -> Enforce quota -> Serve
|
||||
}
|
||||
```
|
||||
|
||||
**Route Methods Are Thin Wrappers**:
|
||||
```php
|
||||
public static function thumbnail_preset(Request $request, array $params = [])
|
||||
{
|
||||
// Parse preset params
|
||||
// Generate cache filename
|
||||
// Call common function
|
||||
return static::generate_and_serve_thumbnail(...);
|
||||
}
|
||||
|
||||
public static function thumbnail(Request $request, array $params = [])
|
||||
{
|
||||
// Parse dynamic params
|
||||
// Generate cache filename
|
||||
// Call common function
|
||||
return static::generate_and_serve_thumbnail(...);
|
||||
}
|
||||
```
|
||||
|
||||
This eliminates duplication while keeping route-specific validation separate.
|
||||
|
||||
## Artisan Commands
|
||||
|
||||
### Thumbnails_Clean_Command
|
||||
|
||||
Enforces quotas for preset and/or dynamic thumbnails.
|
||||
|
||||
**Implementation**:
|
||||
- Scans directory, builds array of [path, size, mtime]
|
||||
- Sorts by mtime ascending (oldest first)
|
||||
- Deletes files until under quota
|
||||
- Reports statistics
|
||||
|
||||
**Flags**: `--preset`, `--dynamic`, `--all` (default)
|
||||
|
||||
### Thumbnails_Generate_Command
|
||||
|
||||
Pre-generates preset thumbnails for attachments.
|
||||
|
||||
**Implementation**:
|
||||
- Queries File_Attachment_Model (optionally filtered by key)
|
||||
- For each attachment/preset combination:
|
||||
- Generate cache filename
|
||||
- Skip if already cached
|
||||
- Generate thumbnail
|
||||
- Save to cache
|
||||
- No quota enforcement (manual command)
|
||||
|
||||
**Use Cases**:
|
||||
- Cache warming after deployment
|
||||
- Background generation for new uploads
|
||||
- Regenerating after changing preset sizes
|
||||
|
||||
### Thumbnails_Stats_Command
|
||||
|
||||
Displays cache usage statistics.
|
||||
|
||||
**Implementation**:
|
||||
- Scans both directories
|
||||
- Calculates: file count, total size, quota percentage, oldest/newest mtime
|
||||
- For presets: breaks down by preset name (extracted from filename)
|
||||
|
||||
## WebP Output
|
||||
|
||||
All thumbnails are WebP format for optimal size/quality:
|
||||
|
||||
```php
|
||||
$image->setImageFormat('webp');
|
||||
$image->setImageCompressionQuality(85);
|
||||
$thumbnail_data = $image->getImageBlob();
|
||||
```
|
||||
|
||||
**Why WebP?**
|
||||
- Superior compression vs JPEG/PNG
|
||||
- Supports transparency (for 'fit' thumbnails)
|
||||
- Universal browser support (96%+ as of 2024)
|
||||
|
||||
## Non-Image Files
|
||||
|
||||
Icon-based thumbnails for PDFs, documents, etc.:
|
||||
|
||||
```php
|
||||
if ($attachment->is_image()) {
|
||||
$thumbnail_data = static::__generate_thumbnail(...);
|
||||
} else {
|
||||
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
|
||||
$attachment->file_extension,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Icons are rendered as WebP images for consistency.
|
||||
|
||||
## Security
|
||||
|
||||
**Authorization**: All thumbnail endpoints check `file.thumbnail.authorize` event gate. Implement handlers in `/rsx/handlers/` to control access.
|
||||
|
||||
**Dimension Limits**: Dynamic thumbnails enforce 10-256 pixel limits to prevent abuse. Preset thumbnails have no limits (developer-controlled).
|
||||
|
||||
**Extension Validation**: File extensions are normalized (lowercase, jpeg→jpg) before cache filename generation.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration in `/system/config/rsx.php` under `thumbnails` key:
|
||||
|
||||
```php
|
||||
'thumbnails' => [
|
||||
'presets' => [
|
||||
'profile' => ['type' => 'cover', 'width' => 200, 'height' => 200],
|
||||
// ...
|
||||
],
|
||||
'quotas' => [
|
||||
'preset_max_bytes' => 100 * 1024 * 1024, // 100MB
|
||||
'dynamic_max_bytes' => 50 * 1024 * 1024, // 50MB
|
||||
],
|
||||
'touch_on_read' => env('THUMBNAILS_TOUCH_ON_READ', true),
|
||||
'touch_interval' => env('THUMBNAILS_TOUCH_INTERVAL', 600),
|
||||
],
|
||||
```
|
||||
|
||||
## File_Thumbnail_Model Removal
|
||||
|
||||
**Previous Design**: Database table tracking thumbnails with source/thumbnail storage relationships.
|
||||
|
||||
**Why Removed**:
|
||||
- Filesystem is sufficient for caching
|
||||
- mtime provides LRU info
|
||||
- No need for DB overhead
|
||||
- Simpler, faster, less to maintain
|
||||
|
||||
**Migration**: `2025_11_16_093331_drop_file_thumbnails_table.php` drops the table.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Preset thumbnail generation and caching
|
||||
- [ ] Dynamic thumbnail generation and caching
|
||||
- [ ] Cache hits serve from disk
|
||||
- [ ] Cache misses generate and cache
|
||||
- [ ] Race condition handling (file deleted between check and open)
|
||||
- [ ] Dynamic quota enforcement deletes oldest first
|
||||
- [ ] mtime touching respects config and interval
|
||||
- [ ] Preset/dynamic survive `rsx:clean`
|
||||
- [ ] Same hash + different extension creates separate thumbnails
|
||||
- [ ] Invalid preset name throws exception
|
||||
- [ ] All generated thumbnails are WebP
|
||||
- [ ] `rsx:thumbnails:clean` enforces quotas
|
||||
- [ ] `rsx:thumbnails:generate` pre-generates correctly
|
||||
- [ ] `rsx:thumbnails:stats` shows accurate data
|
||||
- [ ] Icon-based thumbnails for non-images work
|
||||
- [ ] Browser cache headers correct (1 year)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Not in current implementation but planned:
|
||||
|
||||
- **Animated thumbnails**: Extract first frame from GIF/WebP/video
|
||||
- **Video thumbnails**: Frame extraction via FFmpeg
|
||||
- **PDF thumbnails**: First page via Imagick
|
||||
- **CDN integration**: Upload thumbnails to CDN storage
|
||||
- **Per-preset quotas**: Different limits for different presets
|
||||
- **Background generation**: Queue-based thumbnail generation
|
||||
- **Analytics**: DB-backed usage tracking (optional)
|
||||
@@ -186,6 +186,11 @@ class Ajax {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle flash_alerts from server
|
||||
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
|
||||
Server_Side_Flash.process(response.flash_alerts);
|
||||
}
|
||||
|
||||
// Check if the response was successful
|
||||
if (response._success === true) {
|
||||
// @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value
|
||||
|
||||
@@ -249,7 +249,7 @@ class Debugger {
|
||||
Debugger._console_timer = null;
|
||||
|
||||
try {
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_console_messages'), { messages: messages });
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller::log_console_messages'), { messages: messages });
|
||||
} catch (error) {
|
||||
// Silently fail - don't create error loop
|
||||
console.error('Failed to send console_debug messages to server:', error);
|
||||
@@ -270,7 +270,7 @@ class Debugger {
|
||||
Debugger._error_batch_count++;
|
||||
|
||||
try {
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_browser_errors'), { errors: errors });
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller::log_browser_errors'), { errors: errors });
|
||||
} catch (error) {
|
||||
// Silently fail - don't create error loop
|
||||
console.error('Failed to send browser errors to server:', error);
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
/**
|
||||
* Form utilities for validation and error handling
|
||||
*/
|
||||
class Form_Utils {
|
||||
/**
|
||||
* Framework initialization hook to register jQuery plugin
|
||||
* Creates $.fn.ajax_submit() for form elements
|
||||
* @private
|
||||
*/
|
||||
static _on_framework_core_define(params = {}) {
|
||||
$.fn.ajax_submit = function(options = {}) {
|
||||
const $element = $(this);
|
||||
|
||||
if (!$element.is('form')) {
|
||||
throw new Error('ajax_submit() can only be called on form elements');
|
||||
}
|
||||
|
||||
const url = $element.attr('action');
|
||||
if (!url) {
|
||||
throw new Error('Form must have an action attribute');
|
||||
}
|
||||
|
||||
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
|
||||
|
||||
return Form_Utils.ajax_submit($element, controller, action, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows form validation errors
|
||||
*
|
||||
* REQUIRED HTML STRUCTURE:
|
||||
* For inline field errors to display properly, form fields must follow this structure:
|
||||
*
|
||||
* <div class="form-group">
|
||||
* <label class="form-label" for="field-name">Field Label</label>
|
||||
* <input class="form-control" id="field-name" name="field-name" type="text">
|
||||
* </div>
|
||||
*
|
||||
* Key requirements:
|
||||
* - Wrap each field in a container with class "form-group" (or "form-check" / "input-group")
|
||||
* - Input must have a "name" attribute matching the error key
|
||||
* - Use "form-control" class on inputs for Bootstrap 5 styling
|
||||
*
|
||||
* Accepts three formats:
|
||||
* - String: Single error shown as alert
|
||||
* - Array of strings: Multiple errors shown as bulleted alert
|
||||
* - Object: Field names mapped to errors, shown inline (unmatched shown as alert)
|
||||
*
|
||||
* @param {string} parent_selector - jQuery selector for parent element
|
||||
* @param {string|Object|Array} errors - Error messages to display
|
||||
* @returns {Promise} Promise that resolves when all animations complete
|
||||
*/
|
||||
static apply_form_errors(parent_selector, errors) {
|
||||
console.error(errors);
|
||||
|
||||
const $parent = $(parent_selector);
|
||||
|
||||
// Reset the form errors before applying new ones
|
||||
Form_Utils.reset_form_errors(parent_selector);
|
||||
|
||||
// Normalize input to standard format
|
||||
const normalized = Form_Utils._normalize_errors(errors);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let animations = [];
|
||||
|
||||
if (normalized.type === 'string') {
|
||||
// Single error message
|
||||
animations = Form_Utils._apply_general_errors($parent, normalized.data);
|
||||
} else if (normalized.type === 'array') {
|
||||
// Array of error messages
|
||||
const deduplicated = Form_Utils._deduplicate_errors(normalized.data);
|
||||
animations = Form_Utils._apply_general_errors($parent, deduplicated);
|
||||
} else if (normalized.type === 'fields') {
|
||||
// Field-specific errors
|
||||
const result = Form_Utils._apply_field_errors($parent, normalized.data);
|
||||
animations = result.animations;
|
||||
|
||||
// Count matched fields
|
||||
const matched_count = Object.keys(normalized.data).length - Object.keys(result.unmatched).length;
|
||||
const unmatched_deduplicated = Form_Utils._deduplicate_errors(result.unmatched);
|
||||
const unmatched_count = Object.keys(unmatched_deduplicated).length;
|
||||
|
||||
// Show summary alert if there are any field errors (matched or unmatched)
|
||||
if (matched_count > 0 || unmatched_count > 0) {
|
||||
// Build summary message
|
||||
let summary_msg = '';
|
||||
if (matched_count > 0) {
|
||||
summary_msg = matched_count === 1
|
||||
? 'Please correct the error highlighted below.'
|
||||
: 'Please correct the errors highlighted below.';
|
||||
}
|
||||
|
||||
// If there are unmatched errors, add them as a bulleted list
|
||||
if (unmatched_count > 0) {
|
||||
const summary_animations = Form_Utils._apply_combined_error($parent, summary_msg, unmatched_deduplicated);
|
||||
animations.push(...summary_animations);
|
||||
} else {
|
||||
// Just the summary message, no unmatched errors
|
||||
const summary_animations = Form_Utils._apply_general_errors($parent, summary_msg);
|
||||
animations.push(...summary_animations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the promise once all animations are complete
|
||||
Promise.all(animations).then(() => {
|
||||
// Scroll to error container if it exists
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
if ($error_container.length > 0) {
|
||||
const container_top = $error_container.offset().top;
|
||||
|
||||
// Calculate fixed header offset
|
||||
const fixed_header_height = Form_Utils._get_fixed_header_height();
|
||||
|
||||
// Scroll to position error container 20px below any fixed headers
|
||||
const target_scroll = container_top - fixed_header_height - 20;
|
||||
$('html, body').animate({
|
||||
scrollTop: target_scroll
|
||||
}, 500);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears form validation errors and resets all form values to defaults
|
||||
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
|
||||
*/
|
||||
static reset(form_selector) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
|
||||
Form_Utils.reset_form_errors(form_selector);
|
||||
$form.trigger('reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes form data into key-value object
|
||||
* Returns all input elements with name attributes as object properties
|
||||
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
|
||||
* @returns {Object} Form data as key-value pairs
|
||||
*/
|
||||
static serialize(form_selector) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
const data = {};
|
||||
|
||||
$form.serializeArray().forEach((item) => {
|
||||
data[item.name] = item.value;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits form to RSX controller action via AJAX
|
||||
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
|
||||
* @param {string} controller - Controller class name (e.g., 'User_Controller')
|
||||
* @param {string} action - Action method name (e.g., 'save_profile')
|
||||
* @param {Object} options - Optional configuration {on_success: fn, on_error: fn}
|
||||
* @returns {Promise} Promise that resolves with response data
|
||||
*/
|
||||
static async ajax_submit(form_selector, controller, action, options = {}) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
const form_data = Form_Utils.serialize($form);
|
||||
|
||||
Form_Utils.reset_form_errors(form_selector);
|
||||
|
||||
try {
|
||||
const response = await Ajax.call(controller, action, form_data);
|
||||
|
||||
if (options.on_success) {
|
||||
options.on_success(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.type === 'form_error' && error.details) {
|
||||
await Form_Utils.apply_form_errors(form_selector, error.details);
|
||||
} else {
|
||||
await Form_Utils.apply_form_errors(form_selector, error.message || 'An error occurred');
|
||||
}
|
||||
|
||||
if (options.on_error) {
|
||||
options.on_error(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes form validation errors
|
||||
* @param {string} parent_selector - jQuery selector for parent element
|
||||
*/
|
||||
static reset_form_errors(parent_selector) {
|
||||
const $parent = $(parent_selector);
|
||||
|
||||
// Remove flash messages
|
||||
$('.flash-messages').remove();
|
||||
|
||||
// Remove alert-danger messages
|
||||
$parent.find('.alert-danger').remove();
|
||||
|
||||
// Remove validation error classes and text from form elements
|
||||
$parent.find('.is-invalid').removeClass('is-invalid');
|
||||
$parent.find('.invalid-feedback').remove();
|
||||
}
|
||||
|
||||
// ------------------------
|
||||
|
||||
/**
|
||||
* Normalizes error input into standard formats
|
||||
* @param {string|Object|Array} errors - Raw error input
|
||||
* @returns {Object} Normalized errors as {type: 'string'|'array'|'fields', data: ...}
|
||||
* @private
|
||||
*/
|
||||
static _normalize_errors(errors) {
|
||||
// Handle null/undefined
|
||||
if (!errors) {
|
||||
return { type: 'string', data: 'An error has occurred' };
|
||||
}
|
||||
|
||||
// Handle string
|
||||
if (typeof errors === 'string') {
|
||||
return { type: 'string', data: errors };
|
||||
}
|
||||
|
||||
// Handle array
|
||||
if (Array.isArray(errors)) {
|
||||
// Array of strings - general errors
|
||||
if (errors.every((e) => typeof e === 'string')) {
|
||||
return { type: 'array', data: errors };
|
||||
}
|
||||
// Array with object as first element - extract it
|
||||
if (errors.length > 0 && typeof errors[0] === 'object') {
|
||||
return Form_Utils._normalize_errors(errors[0]);
|
||||
}
|
||||
// Empty or mixed array
|
||||
return { type: 'array', data: [] };
|
||||
}
|
||||
|
||||
// Handle object - check for Laravel response wrapper
|
||||
if (typeof errors === 'object') {
|
||||
// Unwrap {errors: {...}} or {error: {...}}
|
||||
const unwrapped = errors.errors || errors.error;
|
||||
if (unwrapped) {
|
||||
return Form_Utils._normalize_errors(unwrapped);
|
||||
}
|
||||
|
||||
// Convert Laravel validator format {field: [msg1, msg2]} to {field: msg1}
|
||||
const normalized = {};
|
||||
for (const field in errors) {
|
||||
if (errors.hasOwnProperty(field)) {
|
||||
const value = errors[field];
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
normalized[field] = value[0];
|
||||
} else if (typeof value === 'string') {
|
||||
normalized[field] = value;
|
||||
} else {
|
||||
normalized[field] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'fields', data: normalized };
|
||||
}
|
||||
|
||||
// Final catch-all*
|
||||
return { type: 'string', data: String(errors) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate error messages from array or object values
|
||||
* @param {Array|Object} errors - Errors to deduplicate
|
||||
* @returns {Array|Object} Deduplicated errors
|
||||
* @private
|
||||
*/
|
||||
static _deduplicate_errors(errors) {
|
||||
if (Array.isArray(errors)) {
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
|
||||
if (typeof errors === 'object') {
|
||||
const seen = new Set();
|
||||
const result = {};
|
||||
for (const key in errors) {
|
||||
const value = errors[key];
|
||||
if (!seen.has(value)) {
|
||||
seen.add(value);
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies field-specific validation errors to form inputs
|
||||
* @param {jQuery} $parent - Parent element containing form
|
||||
* @param {Object} field_errors - Object mapping field names to error messages
|
||||
* @returns {Object} Object containing {animations: Array, unmatched: Object}
|
||||
* @private
|
||||
*/
|
||||
static _apply_field_errors($parent, field_errors) {
|
||||
const animations = [];
|
||||
const unmatched = {};
|
||||
|
||||
for (const field_name in field_errors) {
|
||||
const error_message = field_errors[field_name];
|
||||
const $input = $parent.find(`[name="${field_name}"]`);
|
||||
|
||||
if (!$input.length) {
|
||||
unmatched[field_name] = error_message;
|
||||
continue;
|
||||
}
|
||||
|
||||
const $error = $('<div class="invalid-feedback"></div>').html(error_message);
|
||||
const $target = $input.closest('.form-group, .form-check, .input-group');
|
||||
|
||||
if (!$target.length) {
|
||||
unmatched[field_name] = error_message;
|
||||
continue;
|
||||
}
|
||||
|
||||
$input.addClass('is-invalid');
|
||||
$error.appendTo($target);
|
||||
animations.push($error.hide().fadeIn(300).promise());
|
||||
}
|
||||
|
||||
return { animations, unmatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies combined error message with summary and unmatched field errors
|
||||
* @param {jQuery} $parent - Parent element containing form
|
||||
* @param {string} summary_msg - Summary message (e.g., "Please correct the errors below")
|
||||
* @param {Object} unmatched_errors - Object of field errors that couldn't be matched to fields
|
||||
* @returns {Array} Array of animation promises
|
||||
* @private
|
||||
*/
|
||||
static _apply_combined_error($parent, summary_msg, unmatched_errors) {
|
||||
const animations = [];
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
const $target = $error_container.length > 0 ? $error_container : $parent;
|
||||
|
||||
// Create alert with summary message and bulleted list of unmatched errors
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"></div>');
|
||||
|
||||
// Add summary message if provided
|
||||
if (summary_msg) {
|
||||
$('<p class="mb-2"></p>').text(summary_msg).appendTo($alert);
|
||||
}
|
||||
|
||||
// Add unmatched errors as bulleted list
|
||||
if (Object.keys(unmatched_errors).length > 0) {
|
||||
const $list = $('<ul class="mb-0"></ul>');
|
||||
for (const field_name in unmatched_errors) {
|
||||
const error_msg = unmatched_errors[field_name];
|
||||
$('<li></li>').html(error_msg).appendTo($list);
|
||||
}
|
||||
$list.appendTo($alert);
|
||||
}
|
||||
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies general error messages as alert box
|
||||
* @param {jQuery} $parent - Parent element to prepend alert to
|
||||
* @param {string|Array} messages - Error message(s) to display
|
||||
* @returns {Array} Array of animation promises
|
||||
* @private
|
||||
*/
|
||||
static _apply_general_errors($parent, messages) {
|
||||
const animations = [];
|
||||
|
||||
// Look for a specific error container div (e.g., in Rsx_Form component)
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
const $target = $error_container.length > 0 ? $error_container : $parent;
|
||||
|
||||
if (typeof messages === 'string') {
|
||||
// Single error - simple alert without list
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
} else if (Array.isArray(messages) && messages.length > 0) {
|
||||
// Multiple errors - bulleted list
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"><ul class="mb-0"></ul></div>');
|
||||
const $list = $alert.find('ul');
|
||||
|
||||
messages.forEach((msg) => {
|
||||
const text = (msg + '').trim() || 'An error has occurred';
|
||||
$('<li></li>').html(text).appendTo($list);
|
||||
});
|
||||
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
} else if (typeof messages === 'object' && !Array.isArray(messages)) {
|
||||
// Object of unmatched field errors - convert to array
|
||||
const error_list = Object.values(messages)
|
||||
.map((v) => String(v).trim())
|
||||
.filter((v) => v);
|
||||
if (error_list.length > 0) {
|
||||
return Form_Utils._apply_general_errors($parent, error_list);
|
||||
}
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total height of fixed/sticky headers at the top of the page
|
||||
* @returns {number} Total height in pixels of fixed top elements
|
||||
* @private
|
||||
*/
|
||||
static _get_fixed_header_height() {
|
||||
let total_height = 0;
|
||||
|
||||
// Find all fixed or sticky positioned elements
|
||||
$('*').each(function() {
|
||||
const $el = $(this);
|
||||
const position = $el.css('position');
|
||||
|
||||
// Only check fixed or sticky elements
|
||||
if (position !== 'fixed' && position !== 'sticky') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if element is positioned at or near the top
|
||||
const top = parseInt($el.css('top')) || 0;
|
||||
if (top > 50) {
|
||||
return; // Not a top header
|
||||
}
|
||||
|
||||
// Check if element is visible
|
||||
if (!$el.is(':visible')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if element spans significant width (likely a header/navbar)
|
||||
const width = $el.outerWidth();
|
||||
const viewport_width = $(window).width();
|
||||
if (width < viewport_width * 0.5) {
|
||||
return; // Too narrow to be a header
|
||||
}
|
||||
|
||||
// Add this element's height
|
||||
total_height += $el.outerHeight();
|
||||
});
|
||||
|
||||
return total_height;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
* if (Rsx.is_dev()) { console.log('Development mode'); }
|
||||
*
|
||||
* // Route generation
|
||||
* const url = Rsx.Route('Controller', 'action').url();
|
||||
* const url = Rsx.Route('Controller::action').url();
|
||||
*
|
||||
* // Unique IDs
|
||||
* const uniqueId = Rsx.uid(); // e.g., "rsx_1234567890_1"
|
||||
@@ -134,70 +134,99 @@ class Rsx {
|
||||
static _routes = {};
|
||||
|
||||
/**
|
||||
* Define routes from bundled data
|
||||
* Called by generated JavaScript in bundles
|
||||
* Calculate scope key from current environment.
|
||||
*
|
||||
* Scope key is a hash key which includes the current value of the session, user, site, and build keys.
|
||||
* Data hashed with this key will be scoped to the current logged in user, and will be invalidated if
|
||||
* the user logs out or the application source code is updated / redeployed / etc.
|
||||
*
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
static _define_routes(routes) {
|
||||
// Merge routes into the global route storage
|
||||
for (const class_name in routes) {
|
||||
if (!Rsx._routes[class_name]) {
|
||||
Rsx._routes[class_name] = {};
|
||||
}
|
||||
for (const method_name in routes[class_name]) {
|
||||
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
|
||||
}
|
||||
static scope_key() {
|
||||
const parts = [];
|
||||
|
||||
// Get session hash (hashed on server for non-reversible scoping)
|
||||
if (window.rsxapp?.session_hash) {
|
||||
parts.push(window.rsxapp.session_hash);
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
if (window.rsxapp?.user?.id) {
|
||||
parts.push(window.rsxapp.user.id);
|
||||
}
|
||||
|
||||
// Get site ID
|
||||
if (window.rsxapp?.site?.id) {
|
||||
parts.push(window.rsxapp.site.id);
|
||||
}
|
||||
|
||||
// Get build key
|
||||
if (window.rsxapp?.build_key) {
|
||||
parts.push(window.rsxapp.build_key);
|
||||
}
|
||||
|
||||
return parts.join('_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL for a controller route
|
||||
* Generate URL for a controller route or SPA action
|
||||
*
|
||||
* This method generates URLs for controller actions by looking up route patterns
|
||||
* and replacing parameters. It handles both regular routes and Ajax endpoints.
|
||||
* This method generates URLs by looking up route patterns and replacing parameters.
|
||||
* It handles controller routes, SPA action routes, and Ajax endpoints.
|
||||
*
|
||||
* If the route is not found in the route definitions, a default pattern is used:
|
||||
* `/_/{controller}/{action}` with all parameters appended as query strings.
|
||||
*
|
||||
* Usage examples:
|
||||
* ```javascript
|
||||
* // Simple route without parameters (defaults to 'index' action)
|
||||
* // Controller route (defaults to 'index' method)
|
||||
* const url = Rsx.Route('Frontend_Index_Controller');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with explicit action
|
||||
* const url = Rsx.Route('Frontend_Index_Controller', 'index');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', 123);
|
||||
* // Controller route with explicit method
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller::view', 123);
|
||||
* // Returns: /clients/view/123
|
||||
*
|
||||
* // SPA action route
|
||||
* const url = Rsx.Route('Contacts_Index_Action');
|
||||
* // Returns: /contacts
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* const url = Rsx.Route('Contacts_View_Action', 123);
|
||||
* // Returns: /contacts/123
|
||||
*
|
||||
* // Route with named parameters (object)
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {id: 'C001'});
|
||||
* // Returns: /clients/view/C001
|
||||
* const url = Rsx.Route('Contacts_View_Action', {id: 'C001'});
|
||||
* // Returns: /contacts/C001
|
||||
*
|
||||
* // Route with required and query parameters
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {
|
||||
* const url = Rsx.Route('Contacts_View_Action', {
|
||||
* id: 'C001',
|
||||
* tab: 'history'
|
||||
* });
|
||||
* // Returns: /clients/view/C001?tab=history
|
||||
*
|
||||
* // Route not found - uses default pattern
|
||||
* const url = Rsx.Route('Unimplemented_Controller', 'some_action', {foo: 'bar'});
|
||||
* // Returns: /_/Unimplemented_Controller/some_action?foo=bar
|
||||
* // Returns: /contacts/C001?tab=history
|
||||
*
|
||||
* // Placeholder route
|
||||
* const url = Rsx.Route('Future_Controller', '#index');
|
||||
* const url = Rsx.Route('Future_Controller::#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param {string} class_name The controller class name (e.g., 'User_Controller')
|
||||
* @param {string} [action_name='index'] The action/method name (defaults to 'index'). Use '#action' for placeholders.
|
||||
* @param {string} action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
|
||||
* @param {number|Object} [params=null] Route parameters. Integer sets 'id', object provides named params.
|
||||
* @returns {string} The generated URL
|
||||
*/
|
||||
static Route(class_name, action_name = 'index', params = null) {
|
||||
static Route(action, params = null) {
|
||||
// Parse action into class_name and action_name
|
||||
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
|
||||
let class_name, action_name;
|
||||
if (action.includes('::')) {
|
||||
[class_name, action_name] = action.split('::', 2);
|
||||
} else {
|
||||
class_name = action;
|
||||
action_name = 'index';
|
||||
}
|
||||
|
||||
// Normalize params to object
|
||||
let params_obj = {};
|
||||
if (typeof params === 'number') {
|
||||
@@ -213,13 +242,19 @@ class Rsx {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Check if route exists in definitions
|
||||
// Check if route exists in PHP controller definitions
|
||||
let pattern;
|
||||
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
|
||||
pattern = Rsx._routes[class_name][action_name];
|
||||
} else {
|
||||
// Route not found - use default pattern /_/{controller}/{action}
|
||||
pattern = `/_/${class_name}/${action_name}`;
|
||||
// Not found in PHP routes - check if it's a SPA action
|
||||
pattern = Rsx._try_spa_action_route(class_name);
|
||||
|
||||
if (!pattern) {
|
||||
// Route not found - use default pattern /_/{controller}/{action}
|
||||
// For SPA actions, action_name defaults to 'index'
|
||||
pattern = `/_/${class_name}/${action_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate URL from pattern
|
||||
@@ -287,6 +322,60 @@ class Rsx {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a route pattern for a SPA action class
|
||||
* Returns the route pattern or null if not found
|
||||
*
|
||||
* @param {string} class_name The action class name
|
||||
* @returns {string|null} The route pattern or null
|
||||
*/
|
||||
static _try_spa_action_route(class_name) {
|
||||
// Get all classes from manifest
|
||||
const all_classes = Manifest.get_all_classes();
|
||||
|
||||
// Find the class by name
|
||||
for (const class_info of all_classes) {
|
||||
if (class_info.class_name === class_name) {
|
||||
const class_object = class_info.class_object;
|
||||
|
||||
// Check if it's a SPA action (has Spa_Action in prototype chain)
|
||||
if (typeof Spa_Action !== 'undefined' &&
|
||||
class_object.prototype instanceof Spa_Action) {
|
||||
|
||||
// Get route patterns from decorator metadata
|
||||
const routes = class_object._spa_routes || [];
|
||||
|
||||
if (routes.length > 0) {
|
||||
// Return the first route pattern
|
||||
return routes[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Found the class but it's not a SPA action or has no routes
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Class not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define routes from bundled data
|
||||
* Called by generated JavaScript in bundles
|
||||
*/
|
||||
static _define_routes(routes) {
|
||||
// Merge routes into the global route storage
|
||||
for (const class_name in routes) {
|
||||
if (!Rsx._routes[class_name]) {
|
||||
Rsx._routes[class_name] = {};
|
||||
}
|
||||
for (const method_name in routes[class_name]) {
|
||||
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Call a specific method on all classes that have it
|
||||
* Collects promises from return values and waits for all to resolve
|
||||
@@ -450,17 +539,17 @@ class Rsx {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all page state from URL hash
|
||||
* Get all hash state from URL hash
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* const state = Rsx.get_all_page_state();
|
||||
* const state = Rsx.url_hash_get_all();
|
||||
* // Returns: {dg_page: '2', dg_sort: 'name'}
|
||||
* ```
|
||||
*
|
||||
* @returns {Object} All hash parameters as key-value pairs
|
||||
*/
|
||||
static get_all_page_state() {
|
||||
static url_hash_get_all() {
|
||||
return Rsx._parse_hash();
|
||||
}
|
||||
|
||||
@@ -469,14 +558,14 @@ class Rsx {
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* const page = Rsx.get_page_state('dg_page');
|
||||
* const page = Rsx.url_hash_get('dg_page');
|
||||
* // Returns: '2' or null if not set
|
||||
* ```
|
||||
*
|
||||
* @param {string} key The key to retrieve
|
||||
* @returns {string|null} The value or null if not found
|
||||
*/
|
||||
static get_page_state(key) {
|
||||
static url_hash_get(key) {
|
||||
const state = Rsx._parse_hash();
|
||||
return state[key] ?? null;
|
||||
}
|
||||
@@ -486,16 +575,16 @@ class Rsx {
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* Rsx.set_page_state('dg_page', 2);
|
||||
* Rsx.url_hash_set_single('dg_page', 2);
|
||||
* // URL becomes: http://example.com/page#dg_page=2
|
||||
*
|
||||
* Rsx.set_page_state('dg_page', null); // Remove key
|
||||
* Rsx.url_hash_set_single('dg_page', null); // Remove key
|
||||
* ```
|
||||
*
|
||||
* @param {string} key The key to set
|
||||
* @param {string|number|null} value The value (null/empty removes the key)
|
||||
*/
|
||||
static set_page_state(key, value) {
|
||||
static url_hash_set_single(key, value) {
|
||||
const state = Rsx._parse_hash();
|
||||
|
||||
// Update or remove the key
|
||||
@@ -516,13 +605,15 @@ class Rsx {
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* Rsx.set_all_page_state({dg_page: 2, dg_sort: 'name'});
|
||||
* Rsx.url_hash_set({dg_page: 2, dg_sort: 'name'});
|
||||
* // URL becomes: http://example.com/page#dg_page=2&dg_sort=name
|
||||
*
|
||||
* Rsx.url_hash_set({dg_page: null}); // Remove key from hash
|
||||
* ```
|
||||
*
|
||||
* @param {Object} new_state Object with key-value pairs to set
|
||||
* @param {Object} new_state Object with key-value pairs to set (null removes key)
|
||||
*/
|
||||
static set_all_page_state(new_state) {
|
||||
static url_hash_set(new_state) {
|
||||
const state = Rsx._parse_hash();
|
||||
|
||||
// Merge new state
|
||||
@@ -601,7 +692,7 @@ class Rsx {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h5>Validation Errors:</h5>
|
||||
<ul class="mb-0">
|
||||
${error_list.map(err => `<li>${Rsx._escape_html(err)}</li>`).join('')}
|
||||
${error_list.map((err) => `<li>${Rsx._escape_html(err)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
357
app/RSpade/Core/Js/Rsx_Storage.js
Executable file
357
app/RSpade/Core/Js/Rsx_Storage.js
Executable file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Rsx_Storage - Scoped browser storage helper with graceful degradation
|
||||
*
|
||||
* Provides safe, scoped access to sessionStorage and localStorage with automatic
|
||||
* handling of unavailable storage, quota exceeded errors, and scope invalidation.
|
||||
*
|
||||
* Key Features:
|
||||
* - **Automatic scoping**: All keys scoped by cookie, user, site, and build version
|
||||
* - **Graceful degradation**: Returns null when storage unavailable (private browsing, etc.)
|
||||
* - **Quota management**: Auto-clears storage when full and retries operation
|
||||
* - **Scope validation**: Clears storage when scope changes (user logout, build update, etc.)
|
||||
* - **Developer-friendly keys**: Scoped suffix allows easy inspection in dev tools
|
||||
*
|
||||
* Scoping Strategy:
|
||||
* Storage is scoped by combining:
|
||||
* - `window.rsxapp.session_hash` (hashed session identifier, non-reversible)
|
||||
* - `window.rsxapp.user.id` (current user)
|
||||
* - `window.rsxapp.site.id` (current site)
|
||||
* - `window.rsxapp.build_key` (application version)
|
||||
*
|
||||
* This scope is stored in `_rsx_scope_key`. If the scope changes between page loads,
|
||||
* all RSpade keys are cleared to prevent stale data from different sessions/users/builds.
|
||||
*
|
||||
* Key Format:
|
||||
* Keys are stored as: `rsx::developer_key::scope_suffix`
|
||||
* Example: `rsx::flash_queue::abc123_42_1_v2.1.0`
|
||||
*
|
||||
* The `rsx::` prefix identifies RSpade keys, allowing safe clearing of only our keys
|
||||
* without affecting other JavaScript libraries. This enables transparent coexistence
|
||||
* with third-party libraries that also use browser storage.
|
||||
*
|
||||
* Quota Exceeded Handling:
|
||||
* When storage quota is exceeded during a set operation, only RSpade keys (prefixed with
|
||||
* `rsx::`) are cleared, preserving other libraries' data. The operation is then retried
|
||||
* once. This ensures the application continues functioning even when storage is full.
|
||||
*
|
||||
* Usage:
|
||||
* // Session storage (cleared on tab close)
|
||||
* Rsx_Storage.session_set('user_preferences', {theme: 'dark'});
|
||||
* const prefs = Rsx_Storage.session_get('user_preferences');
|
||||
* Rsx_Storage.session_remove('user_preferences');
|
||||
*
|
||||
* // Local storage (persists across sessions)
|
||||
* Rsx_Storage.local_set('cached_data', {items: [...]});
|
||||
* const data = Rsx_Storage.local_get('cached_data');
|
||||
* Rsx_Storage.local_remove('cached_data');
|
||||
*
|
||||
* IMPORTANT - Volatile Storage:
|
||||
* Storage can be cleared at any time due to:
|
||||
* - User clearing browser data
|
||||
* - Private browsing mode restrictions
|
||||
* - Quota exceeded errors
|
||||
* - Scope changes (logout, build update, session change)
|
||||
* - Browser storage unavailable
|
||||
*
|
||||
* Therefore, NEVER store critical data that impacts application functionality.
|
||||
* Only store:
|
||||
* - Cached data (performance optimization)
|
||||
* - UI state (convenience, not required)
|
||||
* - Transient messages (flash alerts, notifications)
|
||||
*
|
||||
* If the data is required for the application to function, store it server-side.
|
||||
*/
|
||||
class Rsx_Storage {
|
||||
static _scope_suffix = null;
|
||||
static _session_available = null;
|
||||
static _local_available = null;
|
||||
|
||||
/**
|
||||
* Initialize storage system and validate scope
|
||||
* Called automatically on first access
|
||||
* @private
|
||||
*/
|
||||
static _init() {
|
||||
// Check if already initialized
|
||||
if (this._scope_suffix !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check storage availability
|
||||
this._session_available = this._is_storage_available('sessionStorage');
|
||||
this._local_available = this._is_storage_available('localStorage');
|
||||
|
||||
// Calculate current scope suffix
|
||||
this._scope_suffix = Rsx.scope_key();
|
||||
|
||||
// Validate scope for both storages
|
||||
if (this._session_available) {
|
||||
this._validate_scope(sessionStorage, 'session');
|
||||
}
|
||||
if (this._local_available) {
|
||||
this._validate_scope(localStorage, 'local');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a storage type is available
|
||||
* @param {string} type - 'sessionStorage' or 'localStorage'
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
static _is_storage_available(type) {
|
||||
try {
|
||||
const storage = window[type];
|
||||
const test = '__rsx_storage_test__';
|
||||
storage.setItem(test, test);
|
||||
storage.removeItem(test);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate storage scope and clear RSpade keys if changed
|
||||
* Only clears keys prefixed with 'rsx::' to preserve other libraries' data
|
||||
* @param {Storage} storage - sessionStorage or localStorage
|
||||
* @param {string} type - 'session' or 'local' (for logging)
|
||||
* @private
|
||||
*/
|
||||
static _validate_scope(storage, type) {
|
||||
try {
|
||||
const stored_scope = storage.getItem('_rsx_scope_key');
|
||||
|
||||
// If scope key exists and has changed, clear only RSpade keys
|
||||
if (stored_scope !== null && stored_scope !== this._scope_suffix) {
|
||||
console.log(`[Rsx_Storage] Scope changed for ${type}Storage, clearing RSpade keys only:`, {
|
||||
old_scope: stored_scope,
|
||||
new_scope: this._scope_suffix,
|
||||
});
|
||||
this._clear_rsx_keys(storage);
|
||||
storage.setItem('_rsx_scope_key', this._scope_suffix);
|
||||
} else if (stored_scope === null) {
|
||||
// First time RSpade is using this storage - just set the key, don't clear
|
||||
console.log(`[Rsx_Storage] Initializing scope for ${type}Storage (first use):`, {
|
||||
new_scope: this._scope_suffix,
|
||||
});
|
||||
storage.setItem('_rsx_scope_key', this._scope_suffix);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Rsx_Storage] Failed to validate scope for ${type}Storage:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only RSpade keys from storage (keys starting with 'rsx::')
|
||||
* Preserves keys from other libraries
|
||||
* @param {Storage} storage - sessionStorage or localStorage
|
||||
* @private
|
||||
*/
|
||||
static _clear_rsx_keys(storage) {
|
||||
const keys_to_remove = [];
|
||||
|
||||
// Collect all RSpade keys
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key && key.startsWith('rsx::')) { // @JS-DEFENSIVE-01-EXCEPTION - Browser API storage.key(i) can return null when i >= storage.length
|
||||
keys_to_remove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove collected keys
|
||||
keys_to_remove.forEach((key) => {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch (e) {
|
||||
console.error('[Rsx_Storage] Failed to remove key:', key, e);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Rsx_Storage] Cleared ${keys_to_remove.length} RSpade keys`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build scoped key with RSpade namespace prefix
|
||||
* @param {string} key - Developer-provided key
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
static _build_key(key) {
|
||||
return `rsx::${key}::${this._scope_suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in sessionStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store (will be JSON serialized)
|
||||
*/
|
||||
static session_set(key, value) {
|
||||
this._init();
|
||||
|
||||
if (!this._session_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._set_item(sessionStorage, key, value, 'session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item from sessionStorage
|
||||
* @param {string} key - Storage key
|
||||
* @returns {*|null} Parsed value or null if not found/unavailable
|
||||
*/
|
||||
static session_get(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._session_available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._get_item(sessionStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from sessionStorage
|
||||
* @param {string} key - Storage key
|
||||
*/
|
||||
static session_remove(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._session_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._remove_item(sessionStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store (will be JSON serialized)
|
||||
*/
|
||||
static local_set(key, value) {
|
||||
this._init();
|
||||
|
||||
if (!this._local_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._set_item(localStorage, key, value, 'local');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @returns {*|null} Parsed value or null if not found/unavailable
|
||||
*/
|
||||
static local_get(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._local_available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._get_item(localStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
*/
|
||||
static local_remove(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._local_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._remove_item(localStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal set implementation with scope validation and quota handling
|
||||
* @param {Storage} storage
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
* @param {string} type - 'session' or 'local' (for logging)
|
||||
* @private
|
||||
*/
|
||||
static _set_item(storage, key, value, type) {
|
||||
// Validate scope before every write
|
||||
this._validate_scope(storage, type);
|
||||
|
||||
const scoped_key = this._build_key(key);
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
// Check size - skip if larger than 1MB
|
||||
const size_bytes = new Blob([serialized]).size;
|
||||
const size_mb = size_bytes / (1024 * 1024);
|
||||
|
||||
if (size_mb > 1) {
|
||||
console.warn(`[Rsx_Storage] Skipping storage for key "${key}" - data too large (${size_mb.toFixed(2)} MB, limit 1 MB)`);
|
||||
return;
|
||||
}
|
||||
|
||||
storage.setItem(scoped_key, serialized);
|
||||
} catch (e) {
|
||||
// Check if quota exceeded
|
||||
if (e.name === 'QuotaExceededError' || e.code === 22) {
|
||||
console.warn(`[Rsx_Storage] Quota exceeded for ${type}Storage, clearing RSpade keys and retrying`);
|
||||
|
||||
// Clear only RSpade keys and retry once
|
||||
this._clear_rsx_keys(storage);
|
||||
storage.setItem('_rsx_scope_key', this._scope_suffix);
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
storage.setItem(scoped_key, serialized);
|
||||
} catch (retry_error) {
|
||||
console.error(`[Rsx_Storage] Failed to set item after clearing RSpade keys from ${type}Storage:`, retry_error);
|
||||
}
|
||||
} else {
|
||||
console.error(`[Rsx_Storage] Failed to set item in ${type}Storage:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal get implementation
|
||||
* @param {Storage} storage
|
||||
* @param {string} key
|
||||
* @returns {*|null}
|
||||
* @private
|
||||
*/
|
||||
static _get_item(storage, key) {
|
||||
const scoped_key = this._build_key(key);
|
||||
|
||||
try {
|
||||
const serialized = storage.getItem(scoped_key);
|
||||
if (serialized === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(serialized);
|
||||
} catch (e) {
|
||||
console.error('[Rsx_Storage] Failed to get item:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal remove implementation
|
||||
* @param {Storage} storage
|
||||
* @param {string} key
|
||||
* @private
|
||||
*/
|
||||
static _remove_item(storage, key) {
|
||||
const scoped_key = this._build_key(key);
|
||||
|
||||
try {
|
||||
storage.removeItem(scoped_key);
|
||||
} catch (e) {
|
||||
console.error('[Rsx_Storage] Failed to remove item:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,3 +459,79 @@ function csv_to_array_trim(str_csv) {
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -840,100 +840,22 @@ class Manifest
|
||||
|
||||
/**
|
||||
* Get all routes from the manifest
|
||||
*
|
||||
* Returns unified route structure: $routes[$pattern] => route_data
|
||||
* where route_data contains:
|
||||
* - methods: ['GET', 'POST']
|
||||
* - type: 'spa' | 'standard'
|
||||
* - class: Full class name
|
||||
* - method: Method name
|
||||
* - file: File path
|
||||
* - require: Auth requirements
|
||||
* - js_action_class: (SPA routes only) JavaScript action class
|
||||
*/
|
||||
public static function get_routes(): array
|
||||
{
|
||||
static::init();
|
||||
|
||||
$routes = [
|
||||
'controllers' => [],
|
||||
'api' => [],
|
||||
];
|
||||
|
||||
// Look for Route attributes - must check all namespaces since Route is not a real class
|
||||
// PHP attributes without an import will use the current namespace
|
||||
$files = static::get_all();
|
||||
$route_classes = [];
|
||||
|
||||
foreach ($files as $file => $metadata) {
|
||||
// Check public static method attributes for any attribute ending with 'Route'
|
||||
if (isset($metadata['public_static_methods'])) {
|
||||
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
|
||||
if (isset($method_data['attributes'])) {
|
||||
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
|
||||
// Check if this is a Route attribute (ends with \Route or is just Route)
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') {
|
||||
$route_classes[] = [
|
||||
'file' => $file,
|
||||
'class' => $metadata['class'] ?? null,
|
||||
'fqcn' => $metadata['fqcn'] ?? null,
|
||||
'method' => $method_name,
|
||||
'type' => 'method',
|
||||
'instances' => $attr_instances,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($route_classes as $item) {
|
||||
if ($item['type'] === 'method') {
|
||||
foreach ($item['instances'] as $route_args) {
|
||||
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
|
||||
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
|
||||
$name = $route_args[2] ?? ($route_args['name'] ?? null);
|
||||
|
||||
if ($pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($pattern[0] !== '/') {
|
||||
$pattern = '/' . $pattern;
|
||||
}
|
||||
|
||||
// Determine type (API or controller)
|
||||
$type = str_contains($item['file'], '/api/') || str_contains($item['class'] ?? '', 'Api') ? 'api' : 'controllers';
|
||||
|
||||
// Initialize route if not exists
|
||||
if (!isset($routes[$type][$pattern])) {
|
||||
$routes[$type][$pattern] = [];
|
||||
}
|
||||
|
||||
// Extract Auth attributes for this method from the file metadata
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$item['file']] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'];
|
||||
}
|
||||
|
||||
// Add for each HTTP method
|
||||
foreach ((array) $methods as $method) {
|
||||
$method_upper = strtoupper($method);
|
||||
|
||||
// Initialize method array if not exists
|
||||
if (!isset($routes[$type][$pattern][$method_upper])) {
|
||||
$routes[$type][$pattern][$method_upper] = [];
|
||||
}
|
||||
|
||||
// Add handler to array (allows duplicates for dispatch-time detection)
|
||||
$routes[$type][$pattern][$method_upper][] = [
|
||||
'class' => $item['fqcn'] ?? $item['class'],
|
||||
'method' => $item['method'],
|
||||
'name' => $name,
|
||||
'file' => $item['file'],
|
||||
'require' => $require_attrs,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
|
||||
ksort($routes['controllers']);
|
||||
ksort($routes['api']);
|
||||
|
||||
return $routes;
|
||||
return static::$data['data']['routes'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1115,8 +1037,8 @@ class Manifest
|
||||
if ($loaded_cache && env('APP_ENV') == 'production') {
|
||||
// In prod mode, if cache loaded, assume the cache is good, we are done with it
|
||||
console_debug('MANIFEST', 'Manifest cache loaded successfully (production mode)');
|
||||
self::$_has_manifest_ready = true;
|
||||
\App\RSpade\Core\Autoloader::register();
|
||||
|
||||
self::post_init();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1125,8 +1047,8 @@ class Manifest
|
||||
console_debug('MANIFEST', 'Manifest cache loaded successfully (development mode), validating...');
|
||||
if (self::__validate_cached_data()) {
|
||||
console_debug('MANIFEST', 'Manifest is valid');
|
||||
self::$_has_manifest_ready = true;
|
||||
\App\RSpade\Core\Autoloader::register();
|
||||
|
||||
self::post_init();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1183,8 +1105,37 @@ class Manifest
|
||||
RsxLocks::release_lock(self::$_manifest_compile_lock);
|
||||
console_debug('MANIFEST', 'Released manifest build lock');
|
||||
|
||||
self::post_init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-initialization hook called after manifest is fully loaded
|
||||
*
|
||||
* Called at the end of init() after manifest data is loaded (either from cache
|
||||
* or after scanning/rebuilding). At this point, the manifest is complete and
|
||||
* ready for queries.
|
||||
*
|
||||
* Current responsibilities:
|
||||
* - Sets $_has_manifest_ready flag to indicate manifest is available
|
||||
* - Registers the autoloader (which depends on manifest data)
|
||||
* - Loads classless PHP files (helpers, constants, procedural code)
|
||||
*
|
||||
* This is the appropriate place to perform operations that require the complete
|
||||
* manifest to be available, such as loading non-class PHP files that were
|
||||
* indexed during the scan.
|
||||
*/
|
||||
public static function post_init() {
|
||||
self::$_has_manifest_ready = true;
|
||||
\App\RSpade\Core\Autoloader::register();
|
||||
|
||||
// Load classless PHP files (helper functions, constants, etc.)
|
||||
$classless_files = self::$data['data']['classless_php_files'] ?? [];
|
||||
foreach ($classless_files as $file_path) {
|
||||
$full_path = base_path($file_path);
|
||||
if (file_exists($full_path)) {
|
||||
include_once $full_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1310,6 +1261,7 @@ class Manifest
|
||||
'data' => [
|
||||
'files' => $existing_files,
|
||||
'autoloader_class_map' => [],
|
||||
'routes' => [],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1467,6 +1419,9 @@ class Manifest
|
||||
// Build event handler index from attributes
|
||||
static::__build_event_handler_index();
|
||||
|
||||
// Build classless PHP files index
|
||||
static::__build_classless_php_files_index();
|
||||
|
||||
// =======================================================
|
||||
// Phase 5: Process Modules - Run manifest support modules and build autoloader
|
||||
// =======================================================
|
||||
@@ -1903,6 +1858,36 @@ class Manifest
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build index of classless PHP files
|
||||
*
|
||||
* Creates a simple array of file paths for all PHP files in the manifest
|
||||
* that do not contain a class. These files typically contain helper functions,
|
||||
* constants, or other procedural code that needs to be loaded during post_init().
|
||||
*
|
||||
* Stored in manifest data at: $data['data']['classless_php_files']
|
||||
*/
|
||||
protected static function __build_classless_php_files_index()
|
||||
{
|
||||
static::$data['data']['classless_php_files'] = [];
|
||||
|
||||
// Scan all PHP files
|
||||
foreach (static::$data['data']['files'] as $file_path => $metadata) {
|
||||
// Only process PHP files
|
||||
if (($metadata['extension'] ?? '') !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files that have a class
|
||||
if (!empty($metadata['class'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add file path to classless index
|
||||
static::$data['data']['classless_php_files'][] = $file_path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load changed PHP files and their dependencies
|
||||
*
|
||||
@@ -3167,15 +3152,21 @@ class Manifest
|
||||
|
||||
// Check for conflicting attributes
|
||||
$conflicts = [];
|
||||
if ($has_route) $conflicts[] = 'Route';
|
||||
if ($has_ajax_endpoint) $conflicts[] = 'Ajax_Endpoint';
|
||||
if ($has_task) $conflicts[] = 'Task';
|
||||
if ($has_route) {
|
||||
$conflicts[] = 'Route';
|
||||
}
|
||||
if ($has_ajax_endpoint) {
|
||||
$conflicts[] = 'Ajax_Endpoint';
|
||||
}
|
||||
if ($has_task) {
|
||||
$conflicts[] = 'Task';
|
||||
}
|
||||
|
||||
if (count($conflicts) > 1) {
|
||||
$class_name = $metadata['class'] ?? 'Unknown';
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Method cannot have multiple execution type attributes: " . implode(', ', $conflicts) . "\n" .
|
||||
'Method cannot have multiple execution type attributes: ' . implode(', ', $conflicts) . "\n" .
|
||||
"Class: {$class_name}\n" .
|
||||
"Method: {$method_name}\n" .
|
||||
"File: {$file_path}\n" .
|
||||
|
||||
@@ -18,7 +18,7 @@ class Filename_Suggester
|
||||
* @param string $class_name Class name
|
||||
* @param string $extension File extension (php, js)
|
||||
* @param bool $is_rspade True if in app/RSpade/, false if in rsx/
|
||||
* @param bool $is_jqhtml_component True if JS class extends Jqhtml_Component
|
||||
* @param bool $is_jqhtml_component True if JS class extends Component
|
||||
* @return string Suggested filename
|
||||
*/
|
||||
public static function get_suggested_class_filename(
|
||||
|
||||
@@ -86,6 +86,16 @@ class Rsx_Framework_Provider extends ServiceProvider
|
||||
config(['rsx' => $merged_config]);
|
||||
}
|
||||
|
||||
// Merge additional config from RSX_ADDITIONAL_CONFIG env variable
|
||||
// Used by test runner to include test directory in manifest
|
||||
$additional_config_path = env('RSX_ADDITIONAL_CONFIG');
|
||||
if ($additional_config_path && file_exists($additional_config_path)) {
|
||||
$additional_config = require $additional_config_path;
|
||||
$current_config = config('rsx', []);
|
||||
$merged_config = array_merge_deep($current_config, $additional_config);
|
||||
config(['rsx' => $merged_config]);
|
||||
}
|
||||
|
||||
// Remove .claude/CLAUDE.md symlink if in framework developer mode
|
||||
// This allows dual CLAUDE.md files: one for framework dev, one for distribution
|
||||
// Only runs in specific development environment to avoid affecting other setups
|
||||
@@ -291,6 +301,9 @@ class Rsx_Framework_Provider extends ServiceProvider
|
||||
// Register RSX view namespace for path-agnostic views
|
||||
$this->app['view']->addNamespace('rsx', base_path('rsx'));
|
||||
|
||||
// Register RSpade framework view namespace
|
||||
$this->app['view']->addNamespace('rspade', base_path('app/RSpade'));
|
||||
|
||||
// Register RSX route macro on Route facade
|
||||
if (!Route::hasMacro('rsx')) {
|
||||
Route::macro('rsx', function ($target, $params = []) {
|
||||
|
||||
@@ -9,12 +9,10 @@
|
||||
|
||||
namespace App\RSpade\Core;
|
||||
|
||||
use App\Models\FlashAlert;
|
||||
use RuntimeException;
|
||||
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
|
||||
use App\RSpade\Core\Events\Event_Registry;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Session\Session;
|
||||
|
||||
/**
|
||||
* Core RSX framework utility class
|
||||
@@ -42,14 +40,21 @@ class Rsx
|
||||
*/
|
||||
protected static $current_params = null;
|
||||
|
||||
/**
|
||||
* Current route type ('spa' or 'standard')
|
||||
* @var string|null
|
||||
*/
|
||||
protected static $current_route_type = null;
|
||||
|
||||
/**
|
||||
* Set the current controller and action being executed
|
||||
*
|
||||
* @param string $controller_class The controller class name
|
||||
* @param string $action_method The action method name
|
||||
* @param array $params Optional request params to store
|
||||
* @param string|null $route_type Route type ('spa' or 'standard')
|
||||
*/
|
||||
public static function _set_current_controller_action($controller_class, $action_method, array $params = [])
|
||||
public static function _set_current_controller_action($controller_class, $action_method, array $params = [], $route_type = null)
|
||||
{
|
||||
// Extract just the class name without namespace
|
||||
$parts = explode('\\', $controller_class);
|
||||
@@ -58,6 +63,7 @@ class Rsx
|
||||
static::$current_controller = $class_name;
|
||||
static::$current_action = $action_method;
|
||||
static::$current_params = $params;
|
||||
static::$current_route_type = $route_type;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +96,16 @@ class Rsx
|
||||
return static::$current_params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current route is a SPA route
|
||||
*
|
||||
* @return bool True if current route type is 'spa', false otherwise
|
||||
*/
|
||||
public static function is_spa()
|
||||
{
|
||||
return static::$current_route_type === 'spa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current controller and action tracking
|
||||
*/
|
||||
@@ -98,117 +114,17 @@ class Rsx
|
||||
static::$current_controller = null;
|
||||
static::$current_action = null;
|
||||
static::$current_params = null;
|
||||
static::$current_route_type = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a flash alert message for the current session
|
||||
*
|
||||
* @param string $message The message to display
|
||||
* @param string $class_attribute Optional CSS class attribute (defaults to 'alert alert-danger alert-flash')
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_alert($message, $class_attribute = 'alert alert-danger alert-flash')
|
||||
{
|
||||
$session_id = Session::get_session_id();
|
||||
if ($session_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flash_alert = new FlashAlert();
|
||||
$flash_alert->session_id = $session_id;
|
||||
$flash_alert->message = $message;
|
||||
$flash_alert->class_attribute = $class_attribute;
|
||||
$flash_alert->created_at = now();
|
||||
$flash_alert->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all flash alerts for the current session
|
||||
*
|
||||
* Returns HTML for all flash messages and deletes them from the database.
|
||||
* Messages are rendered as Bootstrap 5 alerts with dismissible buttons.
|
||||
*
|
||||
* @return string HTML string containing all flash alerts or empty string
|
||||
*/
|
||||
public static function render_flash_alerts()
|
||||
{
|
||||
$session_id = Session::get_session_id();
|
||||
if ($session_id === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get all flash alerts for this session
|
||||
$alerts = FlashAlert::where('session_id', $session_id)
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
|
||||
if ($alerts->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Delete the alerts now that we're rendering them
|
||||
FlashAlert::where('session_id', $session_id)
|
||||
->delete();
|
||||
|
||||
// Build HTML for all alerts
|
||||
$html = '';
|
||||
foreach ($alerts as $alert) {
|
||||
$message = htmlspecialchars($alert->message);
|
||||
$class = htmlspecialchars($alert->class_attribute);
|
||||
|
||||
$html .= <<<HTML
|
||||
<div class="{$class} show" role="alert">
|
||||
{$message}
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add a success flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_success($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-success alert-flash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add an error flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_error($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-danger alert-flash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add a warning flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_warning($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-warning alert-flash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add an info flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_info($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-info alert-flash');
|
||||
}
|
||||
// Flash alert methods have been removed - use Flash class instead:
|
||||
// Flash_Alert::success($message)
|
||||
// Flash_Alert::error($message)
|
||||
// Flash_Alert::info($message)
|
||||
// Flash_Alert::warning($message)
|
||||
//
|
||||
// See: /system/app/RSpade/Core/Flash/Flash.php
|
||||
// See: /system/app/RSpade/Core/Flash/CLAUDE.md
|
||||
|
||||
/**
|
||||
* Generate URL for a controller route
|
||||
@@ -223,42 +139,54 @@ HTML;
|
||||
*
|
||||
* Usage examples:
|
||||
* ```php
|
||||
* // Simple route without parameters (defaults to 'index' action)
|
||||
* // Controller route (defaults to 'index' method)
|
||||
* $url = Rsx::Route('Frontend_Index_Controller');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with explicit action
|
||||
* $url = Rsx::Route('Frontend_Index_Controller', 'index');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', 123);
|
||||
* // Controller route with explicit method
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller::view', 123);
|
||||
* // Returns: /clients/view/123
|
||||
*
|
||||
* // SPA action route
|
||||
* $url = Rsx::Route('Contacts_Index_Action');
|
||||
* // Returns: /contacts
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* $url = Rsx::Route('Contacts_View_Action', 123);
|
||||
* // Returns: /contacts/123
|
||||
*
|
||||
* // Route with named parameters (array)
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', ['id' => 'C001']);
|
||||
* // Returns: /clients/view/C001
|
||||
* $url = Rsx::Route('Contacts_View_Action', ['id' => 'C001']);
|
||||
* // Returns: /contacts/C001
|
||||
*
|
||||
* // Route with required and query parameters
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', [
|
||||
* $url = Rsx::Route('Contacts_View_Action', [
|
||||
* 'id' => 'C001',
|
||||
* 'tab' => 'history'
|
||||
* ]);
|
||||
* // Returns: /clients/view/C001?tab=history
|
||||
* // Returns: /contacts/C001?tab=history
|
||||
*
|
||||
* // Placeholder route for scaffolding (controller doesn't need to exist)
|
||||
* $url = Rsx::Route('Future_Feature_Controller', '#index');
|
||||
* // Placeholder route for scaffolding (doesn't need to exist)
|
||||
* $url = Rsx::Route('Future_Feature_Controller::#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param string $class_name The controller class name (e.g., 'User_Controller')
|
||||
* @param string $action_name The action/method name (defaults to 'index'). Use '#action' for placeholders.
|
||||
* @param string $action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
|
||||
* @param int|array|\stdClass|null $params Route parameters. Integer sets 'id', array/object provides named params.
|
||||
* @return string The generated URL
|
||||
* @throws RuntimeException If class doesn't exist, isn't a controller, method doesn't exist, or lacks Route attribute
|
||||
* @throws RuntimeException If class doesn't exist, isn't a controller/action, method doesn't exist, or lacks Route attribute
|
||||
*/
|
||||
public static function Route($class_name, $action_name = 'index', $params = null)
|
||||
public static function Route($action, $params = null)
|
||||
{
|
||||
// Parse action into class_name and action_name
|
||||
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
|
||||
if (str_contains($action, '::')) {
|
||||
[$class_name, $action_name] = explode('::', $action, 2);
|
||||
} else {
|
||||
$class_name = $action;
|
||||
$action_name = 'index';
|
||||
}
|
||||
|
||||
// Normalize params to array
|
||||
$params_array = [];
|
||||
if (is_int($params)) {
|
||||
@@ -281,8 +209,8 @@ HTML;
|
||||
try {
|
||||
$metadata = Manifest::php_get_metadata_by_class($class_name);
|
||||
} catch (RuntimeException $e) {
|
||||
// Report error at caller's location (the blade template or PHP code calling Rsx::Route)
|
||||
throw new Rsx_Caller_Exception("Could not generate route URL: controller class {$class_name} not found");
|
||||
// Not found as PHP class - might be a SPA action, try that instead
|
||||
return static::_try_spa_action_route($class_name, $params_array);
|
||||
}
|
||||
|
||||
// Verify it extends Rsx_Controller_Abstract
|
||||
@@ -366,7 +294,8 @@ HTML;
|
||||
}
|
||||
|
||||
if (!$has_route) {
|
||||
throw new Rsx_Caller_Exception("Method {$class_name}::{$action_name} must have Route or Ajax_Endpoint attribute");
|
||||
// Not a controller method with Route/Ajax - check if it's a SPA action class
|
||||
return static::_try_spa_action_route($class_name, $params_array);
|
||||
}
|
||||
|
||||
if (!$route_pattern) {
|
||||
@@ -377,6 +306,68 @@ HTML;
|
||||
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, $action_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to generate URL for a SPA action class
|
||||
* Called when class lookup fails for controller - checks if it's a JavaScript SPA action
|
||||
*
|
||||
* @param string $class_name The class name (might be a JS SPA action)
|
||||
* @param array $params_array Parameters for URL generation
|
||||
* @return string The generated URL
|
||||
* @throws Rsx_Caller_Exception If not a valid SPA action or route not found
|
||||
*/
|
||||
protected static function _try_spa_action_route(string $class_name, array $params_array): string
|
||||
{
|
||||
// Check if this is a JavaScript class that extends Spa_Action
|
||||
try {
|
||||
$is_spa_action = Manifest::js_is_subclass_of($class_name, 'Spa_Action');
|
||||
} catch (\RuntimeException $e) {
|
||||
// Not a JS class or not found
|
||||
throw new Rsx_Caller_Exception("Class {$class_name} must extend Rsx_Controller_Abstract or Spa_Action");
|
||||
}
|
||||
|
||||
if (!$is_spa_action) {
|
||||
throw new Rsx_Caller_Exception("JavaScript class {$class_name} must extend Spa_Action to generate routes");
|
||||
}
|
||||
|
||||
// Get the file path for this JS class
|
||||
try {
|
||||
$file_path = Manifest::js_find_class($class_name);
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new Rsx_Caller_Exception("SPA action class {$class_name} not found in manifest");
|
||||
}
|
||||
|
||||
// Get file metadata which contains decorator information
|
||||
try {
|
||||
$file_data = Manifest::get_file($file_path);
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new Rsx_Caller_Exception("File metadata not found for SPA action {$class_name}");
|
||||
}
|
||||
|
||||
// Extract route pattern from decorators
|
||||
// JavaScript files have 'decorators' array in their metadata
|
||||
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
|
||||
$route_pattern = null;
|
||||
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
|
||||
foreach ($file_data['decorators'] as $decorator) {
|
||||
// Decorator format: [0 => 'decorator_name', 1 => [arguments]]
|
||||
if (isset($decorator[0]) && $decorator[0] === 'route') {
|
||||
// First argument is the route pattern
|
||||
if (isset($decorator[1][0])) {
|
||||
$route_pattern = $decorator[1][0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$route_pattern) {
|
||||
throw new Rsx_Caller_Exception("SPA action {$class_name} must have @route() decorator with pattern");
|
||||
}
|
||||
|
||||
// Generate URL from pattern using same logic as regular routes
|
||||
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, '(SPA action)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from route pattern by replacing parameters
|
||||
*
|
||||
|
||||
118
app/RSpade/Core/SPA/CLAUDE.md
Executable file
118
app/RSpade/Core/SPA/CLAUDE.md
Executable file
@@ -0,0 +1,118 @@
|
||||
# RSpade Spa System
|
||||
|
||||
## Overview
|
||||
|
||||
The RSpade Spa system enables client-side routing for authenticated areas of applications using the JQHTML component framework.
|
||||
|
||||
## Core Classes
|
||||
|
||||
### Spa_Action
|
||||
Base class for Spa pages/routes. Each action represents a distinct page in the application.
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
class Users_View_Action extends Spa_Action {
|
||||
static route = '/users/:user_id';
|
||||
static layout = 'Frontend_Layout';
|
||||
|
||||
on_create() {
|
||||
console.log(this.args.user_id); // URL parameter from route
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spa_Layout
|
||||
Persistent wrapper component containing navigation, header, footer, etc. Layouts have a content area where actions render.
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
class Frontend_Layout extends Spa_Layout {
|
||||
on_create() {
|
||||
// Initialize layout structure
|
||||
// Create navigation, header, footer
|
||||
// Define content area for actions
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spa
|
||||
Main Spa application class that initializes the router and manages navigation between actions.
|
||||
|
||||
### Spa_Router
|
||||
Core routing engine adapted from JQHTML framework. Handles URL matching, parameter extraction, and navigation.
|
||||
|
||||
## Discovery
|
||||
|
||||
Spa actions are discovered during manifest build using:
|
||||
```php
|
||||
Manifest::is_js_subclass_of('Spa_Action')
|
||||
```
|
||||
|
||||
No filename conventions required - any class extending `Spa_Action` is automatically registered.
|
||||
|
||||
## URL Generation
|
||||
|
||||
**PHP:**
|
||||
```php
|
||||
$url = Rsx::SpaRoute('Users_View_Action', ['user_id' => 123]);
|
||||
// Returns: "/users/123"
|
||||
```
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
const url = Rsx.SpaRoute('Users_View_Action', {user_id: 123, tab: 'posts'});
|
||||
// Returns: "/users/123?tab=posts"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Bootstrap Flow
|
||||
|
||||
1. User requests Spa route (e.g., `/users/123`)
|
||||
2. Dispatcher detects Spa route in manifest
|
||||
3. Server renders `spa_bootstrap.blade.php` with bundle
|
||||
4. Client initializes Spa application
|
||||
5. Router matches URL to action
|
||||
6. Layout renders with action inside
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
1. User clicks link or calls `Rsx.SpaRoute()`
|
||||
2. Router matches URL to action class
|
||||
3. Current action destroyed (if exists)
|
||||
4. New action instantiated with URL parameters
|
||||
5. Action renders within persistent layout
|
||||
6. Browser history updated
|
||||
|
||||
## Implementation Status
|
||||
|
||||
**Phase 1: Foundation Setup** - ✅ COMPLETE
|
||||
- Directory structure created
|
||||
- Placeholder files in place
|
||||
- Router code copied from JQHTML export
|
||||
|
||||
**Phase 2+:** See `/var/www/html/docs.dev/Spa_INTEGRATION_PLAN.md`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
/system/app/RSpade/Core/Jqhtml_Spa/
|
||||
├── CLAUDE.md # This file
|
||||
├── Spa_Bootstrap.php # Server-side bootstrap handler
|
||||
├── Spa_Parser.php # Parse JS files for action metadata
|
||||
├── Spa_Manifest.php # Manifest integration helper
|
||||
├── spa_bootstrap.blade.php # Bootstrap blade layout
|
||||
├── Spa_Router.js # Core router (from JQHTML)
|
||||
├── Spa.js # Spa application base class
|
||||
├── Spa_Layout.js # Layout base class
|
||||
└── Spa_Action.js # Action base class
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Spa routes are auth-only (no SEO concerns)
|
||||
- Server does not render Spa pages (client-side only)
|
||||
- Actions live alongside PHP controllers in feature directories
|
||||
- Controllers provide Ajax endpoints, actions provide UI
|
||||
- Layouts persist across action navigation (no re-render)
|
||||
- Component renamed from `Component` to `Component` (legacy alias maintained)
|
||||
640
app/RSpade/Core/SPA/Spa.js
Executable file
640
app/RSpade/Core/SPA/Spa.js
Executable file
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* Spa - Single Page Application orchestrator for RSpade
|
||||
*
|
||||
* This class manages the Spa lifecycle:
|
||||
* - Auto-discovers action classes extending Spa_Action
|
||||
* - Extracts route information from decorator metadata
|
||||
* - Registers routes with the router
|
||||
* - Dispatches to the current URL
|
||||
*
|
||||
* Initialization happens automatically during the on_app_init phase when
|
||||
* window.rsxapp.is_spa === true.
|
||||
*
|
||||
* Unlike JQHTML, this is a static class (not a Component) following the RS3 pattern.
|
||||
*/
|
||||
class Spa {
|
||||
// Registered routes: { pattern: action_class }
|
||||
static routes = {};
|
||||
|
||||
// Current layout instance
|
||||
static layout = null;
|
||||
|
||||
// Current action instance
|
||||
static action = null;
|
||||
|
||||
// Current route instance
|
||||
static route = null;
|
||||
|
||||
// Current route 'params'
|
||||
static params = null;
|
||||
|
||||
// Flag to prevent re-entrant dispatch
|
||||
static is_dispatching = false;
|
||||
|
||||
/**
|
||||
* Framework module initialization hook called during framework boot
|
||||
* Only runs when window.rsxapp.is_spa === true
|
||||
*/
|
||||
static _on_framework_modules_init() {
|
||||
// Only initialize Spa if we're in a Spa route
|
||||
if (!window.rsxapp || !window.rsxapp.is_spa) {
|
||||
return;
|
||||
}
|
||||
|
||||
console_debug('Spa', 'Initializing Spa system');
|
||||
|
||||
// Discover and register all action classes
|
||||
Spa.discover_actions();
|
||||
|
||||
// Setup browser integration using History API
|
||||
// Note: Navigation API evaluated but not mature enough for production use
|
||||
// See: /docs.dev/SPA_BROWSER_INTEGRATION.md for details
|
||||
console.log('[Spa] Using History API for browser integration');
|
||||
Spa.setup_browser_integration();
|
||||
|
||||
// Dispatch to current URL (including hash for initial load)
|
||||
const initial_url = window.location.pathname + window.location.search + window.location.hash;
|
||||
console_debug('Spa', 'Dispatching to initial URL: ' + initial_url);
|
||||
Spa.dispatch(initial_url, { history: 'none' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all classes extending Spa_Action and register their routes
|
||||
*/
|
||||
static discover_actions() {
|
||||
const all_classes = Manifest.get_all_classes();
|
||||
let action_count = 0;
|
||||
|
||||
for (const class_info of all_classes) {
|
||||
const class_object = class_info.class_object;
|
||||
const class_name = class_info.class_name;
|
||||
|
||||
// Check if this class extends Spa_Action
|
||||
if (class_object.prototype instanceof Spa_Action || class_object === Spa_Action) {
|
||||
// Skip the base class itself
|
||||
if (class_object === Spa_Action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract route patterns from decorator metadata
|
||||
const routes = class_object._spa_routes || [];
|
||||
|
||||
if (routes.length === 0) {
|
||||
console.warn(`Spa: Action ${class_name} has no routes defined`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register each route pattern
|
||||
for (const pattern of routes) {
|
||||
Spa.register_route(pattern, class_object);
|
||||
console_debug('Spa', `Registered route: ${pattern} → ${class_name}`);
|
||||
}
|
||||
|
||||
action_count++;
|
||||
}
|
||||
}
|
||||
|
||||
console_debug('Spa', `Discovered ${action_count} action classes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a route pattern to an action class
|
||||
*/
|
||||
static register_route(pattern, action_class) {
|
||||
// Normalize pattern - remove trailing /index
|
||||
if (pattern.endsWith('/index')) {
|
||||
pattern = pattern.slice(0, -6) || '/';
|
||||
}
|
||||
|
||||
// Check for duplicates in dev mode
|
||||
if (Rsx.is_dev() && Spa.routes[pattern]) {
|
||||
console.error(`Spa: Duplicate route '${pattern}' - ${action_class.name} conflicts with ${Spa.routes[pattern].name}`);
|
||||
}
|
||||
|
||||
Spa.routes[pattern] = action_class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match URL to a route and extract parameters
|
||||
* Returns: { action_class, args, layout } or null
|
||||
*/
|
||||
static match_url_to_route(url) {
|
||||
// Parse URL to get path and query params
|
||||
const parsed = Spa.parse_url(url);
|
||||
let path = parsed.path;
|
||||
|
||||
// Normalize path - remove leading/trailing slashes for matching
|
||||
path = path.substring(1); // Remove leading /
|
||||
|
||||
// Remove /index suffix
|
||||
if (path === 'index' || path.endsWith('/index')) {
|
||||
path = path.slice(0, -5) || '';
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
const exact_pattern = '/' + path;
|
||||
if (Spa.routes[exact_pattern]) {
|
||||
return {
|
||||
action_class: Spa.routes[exact_pattern],
|
||||
args: parsed.query_params,
|
||||
layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout',
|
||||
};
|
||||
}
|
||||
|
||||
// Try pattern matching with :param segments
|
||||
for (const pattern in Spa.routes) {
|
||||
const match = Spa.match_pattern(path, pattern);
|
||||
if (match) {
|
||||
// Merge parameters with correct priority order:
|
||||
// 1. GET parameters (from query string, lowest priority)
|
||||
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
|
||||
// This matches the PHP Dispatcher behavior where route params override GET params
|
||||
const args = { ...parsed.query_params, ...match };
|
||||
|
||||
return {
|
||||
action_class: Spa.routes[pattern],
|
||||
args: args,
|
||||
layout: Spa.routes[pattern]._spa_layout || 'Default_Layout',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No match found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path against a pattern with :param segments
|
||||
* Returns object with extracted params or null if no match
|
||||
*/
|
||||
static match_pattern(path, pattern) {
|
||||
// Remove leading / from both
|
||||
path = path.replace(/^\//, '');
|
||||
pattern = pattern.replace(/^\//, '');
|
||||
|
||||
// Split into segments
|
||||
const path_segments = path.split('/');
|
||||
const pattern_segments = pattern.split('/');
|
||||
|
||||
// Must have same number of segments
|
||||
if (path_segments.length !== pattern_segments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = {};
|
||||
|
||||
for (let i = 0; i < pattern_segments.length; i++) {
|
||||
const pattern_seg = pattern_segments[i];
|
||||
const path_seg = path_segments[i];
|
||||
|
||||
if (pattern_seg.startsWith(':')) {
|
||||
// This is a parameter - extract it
|
||||
const param_name = pattern_seg.substring(1);
|
||||
params[param_name] = decodeURIComponent(path_seg);
|
||||
} else {
|
||||
// This is a literal - must match exactly
|
||||
if (pattern_seg !== path_seg) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL into components
|
||||
*/
|
||||
static parse_url(url) {
|
||||
let parsed_url;
|
||||
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
parsed_url = new URL(url);
|
||||
} else {
|
||||
parsed_url = new URL(url, window.location.href);
|
||||
}
|
||||
} catch (e) {
|
||||
parsed_url = new URL(window.location.href);
|
||||
}
|
||||
|
||||
const path = parsed_url.pathname;
|
||||
const search = parsed_url.search;
|
||||
|
||||
// Parse query string
|
||||
const query_params = {};
|
||||
if (search && search !== '?') {
|
||||
const query = search.startsWith('?') ? search.substring(1) : search;
|
||||
const pairs = query.split('&');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
query_params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { path, search, query_params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from pattern and parameters
|
||||
*/
|
||||
static generate_url_from_pattern(pattern, params = {}) {
|
||||
let url = pattern;
|
||||
const used_params = new Set();
|
||||
|
||||
// Replace :param placeholders
|
||||
url = url.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, param_name) => {
|
||||
if (params.hasOwnProperty(param_name)) {
|
||||
used_params.add(param_name);
|
||||
return encodeURIComponent(params[param_name]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Collect unused parameters for query string
|
||||
const query_params = {};
|
||||
for (const key in params) {
|
||||
if (!used_params.has(key)) {
|
||||
query_params[key] = params[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Add query string if needed
|
||||
if (Object.keys(query_params).length > 0) {
|
||||
const query_string = Object.entries(query_params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
url += '?' + query_string;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup browser integration for back/forward and link interception
|
||||
*
|
||||
* This implements Phase 1 of browser integration using the History API.
|
||||
* See: /docs.dev/SPA_BROWSER_INTEGRATION.md for complete documentation.
|
||||
*
|
||||
* Key Behaviors:
|
||||
* - Intercepts clicks on <a> tags for same-domain SPA routes
|
||||
* - Preserves standard browser behaviors (Ctrl+click, target="_blank", etc.)
|
||||
* - Handles back/forward navigation with scroll restoration
|
||||
* - Hash-only changes don't create history entries
|
||||
* - Defers to server for edge cases (external links, non-SPA routes, etc.)
|
||||
*/
|
||||
static setup_browser_integration() {
|
||||
console_debug('Spa', 'Setting up browser integration (History API mode)');
|
||||
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (e) => {
|
||||
console_debug('Spa', 'popstate event fired (back/forward navigation)');
|
||||
console.warn('[Spa.dispatch] Handling history popstate event', {
|
||||
url: window.location.pathname + window.location.search + window.location.hash,
|
||||
state: e.state
|
||||
});
|
||||
|
||||
// Get target URL (browser has already updated location)
|
||||
const url = window.location.pathname + window.location.search + window.location.hash;
|
||||
|
||||
// Retrieve scroll position from history state
|
||||
const scroll = e.state?.scroll || null;
|
||||
|
||||
// TODO: Form Data Restoration
|
||||
// Retrieve form data from history state and restore after action renders
|
||||
// Implementation notes:
|
||||
// - Get form_data from e.state?.form_data
|
||||
// - After action on_ready(), find all form inputs
|
||||
// - Restore values from form_data object
|
||||
// - Trigger change events for restored fields
|
||||
// - Handle edge cases:
|
||||
// * Dynamic forms (loaded via Ajax) - need to wait for form to exist
|
||||
// * File inputs (cannot be programmatically set for security)
|
||||
// * Custom components (need vals() method for restoration)
|
||||
// * Timing (must restore after form renders, possibly in on_ready)
|
||||
// const form_data = e.state?.form_data || {};
|
||||
|
||||
// Dispatch without modifying history (we're already at the target URL)
|
||||
Spa.dispatch(url, {
|
||||
history: 'none',
|
||||
scroll: scroll
|
||||
});
|
||||
});
|
||||
|
||||
// Intercept link clicks using event delegation
|
||||
document.addEventListener('click', (e) => {
|
||||
// Find <a> tag in event path (handles clicks on child elements)
|
||||
let link = e.target.closest('a');
|
||||
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Ignore if:
|
||||
// - No href
|
||||
// - Ctrl/Cmd/Meta is pressed (open in new tab)
|
||||
// - Has target attribute
|
||||
// - Not left click (button 0)
|
||||
// - Is empty or hash-only (#)
|
||||
if (!href ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
link.getAttribute('target') ||
|
||||
e.button !== 0 ||
|
||||
href === '' ||
|
||||
href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse URLs for comparison
|
||||
const current_parsed = Spa.parse_url(window.location.href);
|
||||
const target_parsed = Spa.parse_url(href);
|
||||
|
||||
// If same page (same path + search), let browser handle (causes reload)
|
||||
// This mimics non-SPA behavior where clicking current page refreshes
|
||||
if (current_parsed.path === target_parsed.path &&
|
||||
current_parsed.search === target_parsed.search) {
|
||||
console_debug('Spa', 'Same page click, letting browser reload');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept same-domain links
|
||||
if (current_parsed.host !== target_parsed.host) {
|
||||
console_debug('Spa', 'External domain link, letting browser handle: ' + href);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if target URL matches a Spa route
|
||||
if (Spa.match_url_to_route(href)) {
|
||||
console_debug('Spa', 'Intercepting link click: ' + href);
|
||||
e.preventDefault();
|
||||
Spa.dispatch(href, { history: 'auto' });
|
||||
} else {
|
||||
console_debug('Spa', 'No SPA route match, letting server handle: ' + href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main dispatch method - navigate to a URL
|
||||
*
|
||||
* This is the single entry point for all navigation within the SPA.
|
||||
* Handles route matching, history management, layout/action lifecycle, and scroll restoration.
|
||||
*
|
||||
* @param {string} url - Target URL (relative or absolute)
|
||||
* @param {object} options - Navigation options
|
||||
* @param {string} options.history - 'auto'|'push'|'replace'|'none' (default: 'auto')
|
||||
* - 'auto': Push for new URLs, replace for same URL
|
||||
* - 'push': Always create new history entry
|
||||
* - 'replace': Replace current history entry
|
||||
* - 'none': Don't modify history (used for back/forward)
|
||||
* @param {object|null} options.scroll - Scroll position {x, y} to restore (default: null = scroll to top)
|
||||
* @param {boolean} options.triggers - Fire before/after dispatch events (default: true)
|
||||
*/
|
||||
static async dispatch(url, options = {}) {
|
||||
if (Spa.is_dispatching) {
|
||||
console.warn('Spa: Already dispatching, ignoring nested dispatch');
|
||||
return;
|
||||
}
|
||||
|
||||
Spa.is_dispatching = true;
|
||||
|
||||
try {
|
||||
const opts = {
|
||||
history: options.history || 'auto',
|
||||
scroll: options.scroll || null,
|
||||
triggers: options.triggers !== false,
|
||||
};
|
||||
|
||||
console_debug('Spa', 'Dispatching to: ' + url + ' (history: ' + opts.history + ')');
|
||||
|
||||
// Handle fully qualified URLs
|
||||
const current_domain = window.location.hostname;
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
try {
|
||||
const parsed_url = new URL(url);
|
||||
|
||||
// Check if different domain
|
||||
if (parsed_url.hostname !== current_domain) {
|
||||
// External domain - navigate away
|
||||
console_debug('Spa', 'External domain, navigating: ' + url);
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (external domain)', {
|
||||
url: url,
|
||||
reason: 'External domain'
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Same domain - strip to relative URL
|
||||
url = parsed_url.pathname + parsed_url.search + parsed_url.hash;
|
||||
console_debug('Spa', 'Same domain, stripped to relative: ' + url);
|
||||
} catch (e) {
|
||||
console.error('Spa: Invalid URL format:', url);
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the (now relative) URL
|
||||
const parsed = Spa.parse_url(url);
|
||||
|
||||
// CRITICAL: Strip hash from URL before route matching
|
||||
// Hash represents page state (e.g., DataGrid page number), not routing state
|
||||
// Hash is preserved in browser URL bar but not used for route matching
|
||||
const url_without_hash = parsed.path + parsed.search;
|
||||
|
||||
console_debug('Spa', 'URL for route matching (hash stripped): ' + url_without_hash);
|
||||
|
||||
// Try to match URL to a route (without hash)
|
||||
const route_match = Spa.match_url_to_route(url_without_hash);
|
||||
|
||||
// Check if this is the same URL we're currently on (without hash)
|
||||
const current_url = window.location.pathname + window.location.search;
|
||||
const is_same_url = url_without_hash === current_url;
|
||||
|
||||
// Same URL navigation with history: 'auto' should reload via server
|
||||
// This mimics non-SPA behavior where clicking current page refreshes
|
||||
if (is_same_url && opts.history === 'auto') {
|
||||
console_debug('Spa', 'Same URL with auto history, letting browser reload');
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (same URL reload)', {
|
||||
url: url,
|
||||
reason: 'Same URL with auto history - mimics browser reload behavior'
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_same_url && !route_match) {
|
||||
// We're being asked to navigate to the current URL, but it doesn't match
|
||||
// any known route. This shouldn't happen - prevents infinite redirect loop.
|
||||
Spa.spa_unknown_route_fatal(parsed.path);
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// If no route match and we're not on this URL, let server handle it
|
||||
if (!route_match) {
|
||||
console_debug('Spa', 'No route matched, letting server handle: ' + url);
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (no route match)', {
|
||||
url: url,
|
||||
reason: 'URL does not match any registered SPA routes'
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console_debug('Spa', 'Route match:', {
|
||||
action_class: route_match?.action_class?.name,
|
||||
args: route_match?.args,
|
||||
layout: route_match?.layout,
|
||||
});
|
||||
|
||||
// Check if action's @spa() attribute matches current SPA bootstrap
|
||||
const action_spa_controller = route_match.action_class._spa_controller_method;
|
||||
const current_spa_controller = window.rsxapp.current_controller + '::' + window.rsxapp.current_action;
|
||||
|
||||
if (action_spa_controller && action_spa_controller !== current_spa_controller) {
|
||||
// Different SPA module - let server bootstrap it
|
||||
console_debug('Spa', 'Different SPA module, letting server handle: ' + url);
|
||||
console_debug('Spa', ` Action uses: ${action_spa_controller}`);
|
||||
console_debug('Spa', ` Current SPA: ${current_spa_controller}`);
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (different SPA module)', {
|
||||
url: url,
|
||||
reason: 'Action belongs to different SPA module/bundle',
|
||||
action_spa_controller: action_spa_controller,
|
||||
current_spa_controller: current_spa_controller
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update browser history with scroll position storage
|
||||
if (opts.history !== 'none') {
|
||||
// Store current scroll position before navigation
|
||||
const current_scroll = {
|
||||
x: window.scrollX || window.pageXOffset,
|
||||
y: window.scrollY || window.pageYOffset
|
||||
};
|
||||
|
||||
// Build history state object
|
||||
const state = {
|
||||
scroll: current_scroll,
|
||||
form_data: {} // Reserved for future form state restoration
|
||||
};
|
||||
|
||||
// Construct full URL with hash (hash is preserved in browser URL bar)
|
||||
const new_url = parsed.path + parsed.search + (parsed.hash || '');
|
||||
|
||||
if (opts.history === 'push' || (opts.history === 'auto' && !is_same_url)) {
|
||||
console_debug('Spa', 'Pushing history state');
|
||||
history.pushState(state, '', new_url);
|
||||
} else if (opts.history === 'replace' || (opts.history === 'auto' && is_same_url)) {
|
||||
console_debug('Spa', 'Replacing history state');
|
||||
history.replaceState(state, '', new_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Set global Spa state
|
||||
Spa.route = route_match;
|
||||
Spa.path = parsed.path;
|
||||
Spa.params = route_match.args;
|
||||
|
||||
// Get layout name and action info
|
||||
const layout_name = route_match.layout;
|
||||
const action_class = route_match.action_class;
|
||||
const action_name = action_class.name;
|
||||
|
||||
// Log successful SPA navigation
|
||||
console.warn('[Spa.dispatch] Executing SPA navigation', {
|
||||
url: url,
|
||||
path: parsed.path,
|
||||
params: route_match.args,
|
||||
action: action_name,
|
||||
layout: layout_name,
|
||||
history_mode: opts.history
|
||||
});
|
||||
|
||||
// Check if we need a new layout
|
||||
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
|
||||
// Stop old layout if exists (auto-stops children)
|
||||
if (Spa.layout) {
|
||||
await Spa.layout.trigger('unload');
|
||||
Spa.layout.stop();
|
||||
}
|
||||
|
||||
// Clear body and create new layout
|
||||
$('body').empty();
|
||||
$('body').attr('class', '');
|
||||
|
||||
// Create layout using component system
|
||||
Spa.layout = $('body').component(layout_name, {}).component();
|
||||
|
||||
// Wait for layout to be ready
|
||||
await Spa.layout.ready();
|
||||
|
||||
console_debug('Spa', `Created layout: ${layout_name}`);
|
||||
} else {
|
||||
// Wait for layout to finish previous action if still loading
|
||||
await Spa.layout.ready();
|
||||
}
|
||||
|
||||
// Tell layout to run the action
|
||||
Spa.layout._set_action(action_name, route_match.args, url);
|
||||
await Spa.layout._run_action();
|
||||
|
||||
// Scroll Restoration #1: Immediate (after action starts)
|
||||
// This occurs synchronously after the action component is created
|
||||
// May fail if page height is insufficient - that's okay, we'll retry later
|
||||
if (opts.scroll) {
|
||||
console_debug('Spa', 'Restoring scroll position (immediate): ' + opts.scroll.x + ', ' + opts.scroll.y);
|
||||
window.scrollTo(opts.scroll.x, opts.scroll.y);
|
||||
} else if (opts.scroll === undefined) {
|
||||
// Default: scroll to top for new navigation (only if scroll not explicitly set)
|
||||
console_debug('Spa', 'Scrolling to top (new navigation)');
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
// If opts.scroll === null, don't scroll (let Navigation API or browser handle it)
|
||||
|
||||
// TODO: Scroll Restoration #2 - After action on_ready() completes
|
||||
// This requires an action lifecycle event system to detect when on_ready() finishes.
|
||||
// Implementation notes:
|
||||
// - Listen for action on_ready completion event
|
||||
// - Only retry scroll if restoration #1 failed (page wasn't tall enough)
|
||||
// - Check if page height has increased since first attempt
|
||||
// - Avoid infinite retry loops (track attempts)
|
||||
// - This ensures scroll restoration works even when content loads asynchronously
|
||||
//
|
||||
// Additional context: The action may load data in on_load() that increases page height,
|
||||
// making the target scroll position accessible. The first restoration happens before
|
||||
// this content renders, so we need a second attempt after the page is fully ready.
|
||||
|
||||
console_debug('Spa', `Rendered ${action_name} in ${layout_name}`);
|
||||
} catch (error) {
|
||||
console.error('[Spa] Dispatch error:', error);
|
||||
// TODO: Better error handling - show error UI to user
|
||||
throw error;
|
||||
} finally {
|
||||
Spa.is_dispatching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fatal error when trying to navigate to unknown route on current URL
|
||||
* This shouldn't happen - prevents infinite redirect loops
|
||||
*/
|
||||
static spa_unknown_route_fatal(path) {
|
||||
console.error(`Unknown route for path ${path} - this shouldn't happen`);
|
||||
}
|
||||
}
|
||||
81
app/RSpade/Core/SPA/Spa_Action.js
Executable file
81
app/RSpade/Core/SPA/Spa_Action.js
Executable file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Spa_Action - Base class for Spa action components
|
||||
*
|
||||
* An Action represents a page/route in the Spa. Each action class defines:
|
||||
* - Route pattern(s) via @route() decorator
|
||||
* - Layout to render within via @layout() decorator
|
||||
* - Associated PHP controller via @spa() decorator
|
||||
*
|
||||
* Actions receive URL parameters in this.args and load data in on_load().
|
||||
*
|
||||
* Example:
|
||||
* @route('/contacts')
|
||||
* @layout('Frontend_Layout')
|
||||
* @spa('Frontend_Contacts_Controller::index')
|
||||
* class Contacts_Index_Action extends Spa_Action {
|
||||
* async on_load() {
|
||||
* this.data.contacts = await Contacts_Controller.fetch_all();
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
class Spa_Action extends Component {
|
||||
// constructor(args = {}, options = {}) {
|
||||
// super(args, options);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Called during load phase to fetch data from server
|
||||
* Set this.data properties here
|
||||
*/
|
||||
async on_load() {
|
||||
// Override in subclass
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL for this action class with given parameters
|
||||
* Static method for use without an instance
|
||||
*/
|
||||
static url(params = {}) {
|
||||
const that = this;
|
||||
|
||||
// Get routes from decorator metadata
|
||||
const routes = that._spa_routes || [];
|
||||
|
||||
if (routes.length === 0) {
|
||||
console.error(`Action ${that.name} has no routes defined`);
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Use first route as the pattern
|
||||
// TODO: Implement smart route selection based on params
|
||||
const pattern = routes[0];
|
||||
|
||||
return Spa.generate_url_from_pattern(pattern, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to this action with given parameters
|
||||
* Static method for programmatic navigation
|
||||
*/
|
||||
static dispatch(params = {}) {
|
||||
const that = this;
|
||||
const url = that.url(params);
|
||||
Spa.dispatch(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method: Generate URL with current args merged with new params
|
||||
*/
|
||||
url(params = {}) {
|
||||
const merged_params = { ...this.args, ...params };
|
||||
return this.constructor.url(merged_params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method: Navigate with current args merged with new params
|
||||
*/
|
||||
dispatch(params = {}) {
|
||||
const url = this.url(params);
|
||||
Spa.dispatch(url);
|
||||
}
|
||||
}
|
||||
18
app/RSpade/Core/SPA/Spa_App.blade.php
Executable file
18
app/RSpade/Core/SPA/Spa_App.blade.php
Executable file
@@ -0,0 +1,18 @@
|
||||
@rsx_id('Spa_App')
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<meta content="ie=edge" http-equiv="X-UA-Compatible">
|
||||
|
||||
{{-- Bundle includes --}}
|
||||
{!! Frontend_Bundle::render() !!}
|
||||
</head>
|
||||
|
||||
<body class="{{ rsx_body_class() }}">
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
78
app/RSpade/Core/SPA/Spa_Decorators.js
Executable file
78
app/RSpade/Core/SPA/Spa_Decorators.js
Executable file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Spa Decorator Functions
|
||||
*
|
||||
* These decorators are used on Spa Action classes to define their route patterns,
|
||||
* layouts, and associated PHP controller methods.
|
||||
*
|
||||
* Decorators store metadata as static properties on the class, which Spa.js
|
||||
* reads during initialization to register routes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Define route pattern(s) for this action
|
||||
*
|
||||
* Usage:
|
||||
* @route('/contacts')
|
||||
* @route('/contacts/index')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function route(pattern) {
|
||||
return function (target) {
|
||||
// Store route pattern on the class
|
||||
if (!target._spa_routes) {
|
||||
target._spa_routes = [];
|
||||
}
|
||||
target._spa_routes.push(pattern);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Define which layout this action renders within
|
||||
*
|
||||
* Usage:
|
||||
* @layout('Frontend_Layout')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function layout(layout_name) {
|
||||
return function (target) {
|
||||
// Store layout name on the class
|
||||
target._spa_layout = layout_name;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Link this Spa action to its PHP controller method
|
||||
* Used for generating PHP URLs and understanding the relationship
|
||||
*
|
||||
* Usage:
|
||||
* @spa('Frontend_Contacts_Controller::index')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function spa(controller_method) {
|
||||
return function (target) {
|
||||
// Store controller::method reference on the class
|
||||
target._spa_controller_method = controller_method;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Define the browser page title for this action
|
||||
*
|
||||
* Usage:
|
||||
* @title('Contacts - RSX')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function title(page_title) {
|
||||
return function (target) {
|
||||
// Store page title on the class
|
||||
target._spa_title = page_title;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
150
app/RSpade/Core/SPA/Spa_Layout.js
Executable file
150
app/RSpade/Core/SPA/Spa_Layout.js
Executable file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Spa_Layout - Base class for Spa layouts
|
||||
*
|
||||
* Layouts provide the persistent wrapper (header, nav, footer) around actions.
|
||||
* They render directly to body and contain a content area where actions render.
|
||||
*
|
||||
* Requirements:
|
||||
* - Must have an element with $id="content" where actions will render
|
||||
* - Persists across action navigations (only re-created when layout changes)
|
||||
*
|
||||
* Lifecycle events triggered for actions:
|
||||
* - before_action_init, action_init
|
||||
* - before_action_render, action_render
|
||||
* - before_action_ready, action_ready
|
||||
*
|
||||
* Hook methods that can be overridden:
|
||||
* - on_action(url, action_name, args) - Called when new action is set
|
||||
*/
|
||||
class Spa_Layout extends Component {
|
||||
on_create() {
|
||||
console.log('Layout create!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content container where actions render
|
||||
* @returns {jQuery} The content element
|
||||
*/
|
||||
$content() {
|
||||
return this.$id('content');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set which action should be rendered
|
||||
* Called by Spa.dispatch() - stores action info for _run_action()
|
||||
*
|
||||
* @param {string} action_name - Name of the action class
|
||||
* @param {object} args - URL parameters and query params
|
||||
* @param {string} url - The full URL being dispatched to
|
||||
*/
|
||||
_set_action(action_name, args, url) {
|
||||
this._pending_action_name = action_name;
|
||||
this._pending_action_args = args;
|
||||
this._pending_action_url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the pending action - stop old action, create new one
|
||||
* Called by Spa.dispatch() after _set_action()
|
||||
*/
|
||||
async _run_action() {
|
||||
const action_name = this._pending_action_name;
|
||||
const args = this._pending_action_args;
|
||||
const url = this._pending_action_url;
|
||||
|
||||
// Get content container
|
||||
console.log('[Spa_Layout] Looking for content element...');
|
||||
console.log('[Spa_Layout] this.$id available?', typeof this.$id);
|
||||
console.log('[Spa_Layout] this.$ exists?', !!this.$);
|
||||
|
||||
const $content = this.$content();
|
||||
console.log('[Spa_Layout] $content result:', $content);
|
||||
console.log('[Spa_Layout] $content.length:', $content?.length);
|
||||
|
||||
if (!$content || !$content.length) {
|
||||
// TODO: Better error handling - show error UI instead of just console
|
||||
console.error(`[Spa_Layout] Layout ${this.constructor.name} must have an element with $id="content"`);
|
||||
console.error(
|
||||
'[Spa_Layout] Available elements in this.$:',
|
||||
this.$.find('[data-id]')
|
||||
.toArray()
|
||||
.map((el) => el.getAttribute('data-id'))
|
||||
);
|
||||
throw new Error(`Layout ${this.constructor.name} must have an element with $id="content"`);
|
||||
}
|
||||
|
||||
// Stop old action (jqhtml auto-stops when .component() replaces)
|
||||
// Clear content area
|
||||
$content.empty();
|
||||
|
||||
// Get the action class to check for @title decorator
|
||||
const action_class = Manifest.get_class_by_name(action_name);
|
||||
|
||||
// Update page title if @title decorator is present (optional), clear if not
|
||||
if (action_class._spa_title) {
|
||||
document.title = action_class._spa_title;
|
||||
} else {
|
||||
document.title = '';
|
||||
}
|
||||
|
||||
// Create new action component
|
||||
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
|
||||
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
|
||||
const action = $content.component(action_name, args).component();
|
||||
|
||||
// Store reference
|
||||
Spa.action = action;
|
||||
this.action = action;
|
||||
|
||||
// Call on_action hook (can be overridden by subclasses)
|
||||
this.on_action(url, action_name, args);
|
||||
this.trigger('action');
|
||||
|
||||
// Setup event forwarding from action to layout
|
||||
// Action triggers 'init' -> Layout triggers 'action_init'
|
||||
this._setup_action_events(action);
|
||||
|
||||
// Wait for action to be ready
|
||||
await action.ready();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners on action to forward to layout
|
||||
* @private
|
||||
*/
|
||||
_setup_action_events(action) {
|
||||
const events = ['before_init', 'init', 'before_render', 'render', 'before_ready', 'ready'];
|
||||
|
||||
events.forEach((event) => {
|
||||
action.on(event, () => {
|
||||
// Trigger corresponding layout event with 'action_' prefix
|
||||
const layout_event = event.replace('before_', 'before_action_').replace(/^(?!before)/, 'action_');
|
||||
this.trigger(layout_event, action);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook called when a new action is set
|
||||
* Override this in subclasses to react to action changes.
|
||||
*
|
||||
* on_action can / should be implemented as a async function. but nothing is waiting for it, this is
|
||||
* just defined this way for convienence to make clear that async code can be used and is appropriate
|
||||
* for on_action.
|
||||
*
|
||||
* If it is necessary to wait for the action itself to reach ready state, add:
|
||||
* await this.action.ready();
|
||||
* to the concrete implementation of on_action.
|
||||
*
|
||||
* @param {string} url - The URL being navigated to
|
||||
* @param {string} action_name - Name of the action class
|
||||
* @param {object} args - URL parameters and query params
|
||||
*/
|
||||
async on_action(url, action_name, args) {
|
||||
// Empty by default - override in subclass
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
console.log('layout ready!');
|
||||
}
|
||||
}
|
||||
180
app/RSpade/Core/SPA/Spa_ManifestSupport.php
Executable file
180
app/RSpade/Core/SPA/Spa_ManifestSupport.php
Executable file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\SPA;
|
||||
|
||||
use RuntimeException;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for extracting Spa route metadata from Spa_Action classes
|
||||
* This runs after the primary manifest is built to add Spa routes to the unified routes index
|
||||
*/
|
||||
class Spa_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Spa Routes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the manifest and build Spa routes index
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize routes key if not already set
|
||||
if (!isset($manifest_data['data']['routes'])) {
|
||||
$manifest_data['data']['routes'] = [];
|
||||
}
|
||||
|
||||
// Get all files to look up PHP controller metadata
|
||||
$files = $manifest_data['data']['files'];
|
||||
|
||||
// Get all JavaScript classes extending Spa_Action
|
||||
$action_classes = Manifest::js_get_extending('Spa_Action');
|
||||
|
||||
foreach ($action_classes as $class_name => $action_metadata) {
|
||||
// Extract decorator metadata
|
||||
$decorators = $action_metadata['decorators'] ?? [];
|
||||
|
||||
// Parse decorators into route configuration
|
||||
$route_info = static::_parse_decorators($decorators);
|
||||
|
||||
// Skip if no route decorator found
|
||||
if (empty($route_info['routes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate that @spa decorator is present
|
||||
if (empty($route_info['spa_controller']) || empty($route_info['spa_method'])) {
|
||||
throw new RuntimeException(
|
||||
"Spa action '{$class_name}' is missing required @spa decorator.\n" .
|
||||
"Add @spa('Controller_Class::method') to specify the PHP controller method that serves the Spa bootstrap.\n" .
|
||||
"File: {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Find the PHP controller file and metadata
|
||||
$php_controller_class = $route_info['spa_controller'];
|
||||
$php_controller_method = $route_info['spa_method'];
|
||||
$php_controller_file = null;
|
||||
$php_controller_fqcn = null;
|
||||
|
||||
// Search for the controller in the manifest
|
||||
foreach ($files as $file => $metadata) {
|
||||
if (($metadata['class'] ?? null) === $php_controller_class || ($metadata['fqcn'] ?? null) === $php_controller_class) {
|
||||
$php_controller_file = $file;
|
||||
$php_controller_fqcn = $metadata['fqcn'] ?? $metadata['class'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$php_controller_file) {
|
||||
throw new RuntimeException(
|
||||
"Spa action '{$class_name}' references unknown controller '{$php_controller_class}'.\n" .
|
||||
"The @spa decorator must reference a valid PHP controller class.\n" .
|
||||
"File: {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Extract Auth attributes from the PHP controller method
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$php_controller_file] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$php_controller_method]['attributes']['Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$php_controller_method]['attributes']['Auth'];
|
||||
}
|
||||
|
||||
// Build complete route metadata for each route pattern
|
||||
foreach ($route_info['routes'] as $route_pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($route_pattern[0] !== '/') {
|
||||
$route_pattern = '/' . $route_pattern;
|
||||
}
|
||||
|
||||
// Check for duplicate route definition (pattern must be unique across all route types)
|
||||
if (isset($manifest_data['data']['routes'][$route_pattern])) {
|
||||
$existing = $manifest_data['data']['routes'][$route_pattern];
|
||||
$existing_type = $existing['type'];
|
||||
$existing_location = $existing_type === 'spa'
|
||||
? "Spa action {$existing['js_action_class']} in {$existing['file']}"
|
||||
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
|
||||
|
||||
throw new RuntimeException(
|
||||
"Duplicate route definition: {$route_pattern}\n" .
|
||||
" Already defined: {$existing_location}\n" .
|
||||
" Conflicting: Spa action {$class_name} in {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Store route with unified structure
|
||||
$manifest_data['data']['routes'][$route_pattern] = [
|
||||
'methods' => ['GET'], // Spa routes are always GET
|
||||
'type' => 'spa',
|
||||
'class' => $php_controller_fqcn,
|
||||
'method' => $php_controller_method,
|
||||
'name' => null,
|
||||
'file' => $php_controller_file,
|
||||
'require' => $require_attrs,
|
||||
'js_action_class' => $class_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse decorator metadata into route configuration
|
||||
*
|
||||
* @param array $decorators Array of decorator data from manifest
|
||||
* @return array Parsed route configuration
|
||||
*/
|
||||
private static function _parse_decorators(array $decorators): array
|
||||
{
|
||||
$config = [
|
||||
'routes' => [],
|
||||
'layout' => null,
|
||||
'spa_controller' => null,
|
||||
'spa_method' => null,
|
||||
];
|
||||
|
||||
foreach ($decorators as $decorator) {
|
||||
[$name, $args] = $decorator;
|
||||
|
||||
switch ($name) {
|
||||
case 'route':
|
||||
// @route('/path') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$config['routes'][] = $args[0];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'layout':
|
||||
// @layout('Layout_Name') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$config['layout'] = $args[0];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spa':
|
||||
// @spa('Controller::method') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$parts = explode('::', $args[0]);
|
||||
if (count($parts) === 2) {
|
||||
$config['spa_controller'] = $parts[0];
|
||||
$config['spa_method'] = $parts[1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ class Search_Index_Model extends Rsx_Site_Model_Abstract
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'search_indexes';
|
||||
protected $table = '_search_indexes';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
namespace App\RSpade\Core\Service;
|
||||
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
/**
|
||||
* Base service class for all RSX services
|
||||
*
|
||||
@@ -21,10 +23,11 @@ abstract class Rsx_Service_Abstract
|
||||
* Pre-task hook called before any task execution
|
||||
* Override in child classes to add pre-task logic
|
||||
*
|
||||
* @param Task_Instance $task Task instance for logging and status tracking
|
||||
* @param array $params Task parameters
|
||||
* @return mixed|null Return null to continue, or throw exception to halt
|
||||
*/
|
||||
public static function pre_task(array $params = [])
|
||||
public static function pre_task(Task_Instance $task, array $params = [])
|
||||
{
|
||||
// Default implementation does nothing
|
||||
// Override in child classes to add authentication, validation, logging, etc.
|
||||
|
||||
64
app/RSpade/Core/Session/Session_Cleanup_Service.php
Executable file
64
app/RSpade/Core/Session/Session_Cleanup_Service.php
Executable file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Session;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
/**
|
||||
* Session_Cleanup_Service
|
||||
*
|
||||
* Scheduled cleanup of expired and abandoned sessions.
|
||||
*
|
||||
* Cleanup Rules:
|
||||
* - Logged-in sessions (login_user_id set): Delete if older than 365 days
|
||||
* - Anonymous sessions (login_user_id null): Delete if older than 14 days
|
||||
*
|
||||
* Runs daily at 3 AM via scheduled task.
|
||||
*/
|
||||
class Session_Cleanup_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
/**
|
||||
* Clean up expired and abandoned sessions
|
||||
*
|
||||
* Deletes sessions based on age and login status:
|
||||
* - Logged-in sessions: 365 days retention
|
||||
* - Anonymous sessions: 14 days retention
|
||||
*
|
||||
* @param Task_Instance $task Task instance for logging
|
||||
* @param array $params Task parameters
|
||||
* @return array Cleanup statistics
|
||||
*/
|
||||
#[Task('Clean up expired and abandoned sessions (runs daily at 3 AM)')]
|
||||
#[Schedule('0 3 * * *')]
|
||||
public static function cleanup_sessions(Task_Instance $task, array $params = [])
|
||||
{
|
||||
// Logged-in sessions: older than 365 days
|
||||
$logged_in_cutoff = now()->subDays(365);
|
||||
$logged_in_deleted = DB::table('sessions')
|
||||
->whereNotNull('login_user_id')
|
||||
->where('last_active', '<', $logged_in_cutoff)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$logged_in_deleted} logged-in sessions older than 365 days");
|
||||
|
||||
// Anonymous sessions: older than 14 days
|
||||
$anonymous_cutoff = now()->subDays(14);
|
||||
$anonymous_deleted = DB::table('sessions')
|
||||
->whereNull('login_user_id')
|
||||
->where('last_active', '<', $anonymous_cutoff)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$anonymous_deleted} anonymous sessions older than 14 days");
|
||||
|
||||
$total_deleted = $logged_in_deleted + $anonymous_deleted;
|
||||
$task->info("Total sessions deleted: {$total_deleted}");
|
||||
|
||||
return [
|
||||
'logged_in_deleted' => $logged_in_deleted,
|
||||
'anonymous_deleted' => $anonymous_deleted,
|
||||
'total_deleted' => $total_deleted,
|
||||
];
|
||||
}
|
||||
}
|
||||
295
app/RSpade/Core/Task/CLAUDE.md
Executable file
295
app/RSpade/Core/Task/CLAUDE.md
Executable file
@@ -0,0 +1,295 @@
|
||||
# Task System
|
||||
|
||||
RSpade provides a unified task execution system supporting immediate CLI execution, queued async tasks, and scheduled cron-based tasks.
|
||||
|
||||
## Creating Task Services
|
||||
|
||||
Task services must extend `Rsx_Service_Abstract` and use attributes to define tasks:
|
||||
|
||||
```php
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
class My_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
// Simple task (can be run via CLI or dispatched to queue)
|
||||
#[Task('Description of what this task does')]
|
||||
public static function my_task(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$task->info('Task is running');
|
||||
// Task logic here
|
||||
return ['result' => 'success'];
|
||||
}
|
||||
|
||||
// Scheduled task (runs automatically via cron)
|
||||
#[Task('Cleanup old records')]
|
||||
#[Schedule('0 2 * * *')] // Daily at 2 AM
|
||||
public static function cleanup(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$task->info('Cleanup running');
|
||||
// Cleanup logic here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Class MUST extend `Rsx_Service_Abstract`
|
||||
- Method MUST be `public static`
|
||||
- Method MUST have signature: `(Task_Instance $task, array $params = [])`
|
||||
- Method MUST have `#[Task('description')]` attribute
|
||||
- Scheduled tasks also need `#[Schedule('cron_expression')]` attribute
|
||||
|
||||
## Important Notes on Attributes
|
||||
|
||||
- Attributes work via reflection - NO backing PHP classes needed
|
||||
- The linter will remove `use` statements for attributes - this is INTENTIONAL and CORRECT
|
||||
- Use `#[Task]` and `#[Schedule]`, NOT `#[Task_Attribute]` or any other variation
|
||||
- Never create PHP classes for these attributes
|
||||
|
||||
## Task_Instance Methods
|
||||
|
||||
The `$task` parameter provides logging and progress tracking:
|
||||
|
||||
```php
|
||||
public static function process_items(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$items = Item_Model::all();
|
||||
$total = count($items);
|
||||
|
||||
$task->info("Processing {$total} items");
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
// Update progress
|
||||
$task->progress($index + 1, $total);
|
||||
|
||||
// Log info
|
||||
$task->info("Processing item {$item->id}");
|
||||
|
||||
// Log warnings
|
||||
if ($item->status === 'pending') {
|
||||
$task->warning("Item {$item->id} is still pending");
|
||||
}
|
||||
|
||||
// Process item
|
||||
$item->process();
|
||||
}
|
||||
|
||||
$task->info("Completed processing {$total} items");
|
||||
|
||||
return ['processed' => $total];
|
||||
}
|
||||
```
|
||||
|
||||
## Running Tasks
|
||||
|
||||
### CLI Execution
|
||||
|
||||
```bash
|
||||
# List all available tasks
|
||||
php artisan rsx:task:list
|
||||
|
||||
# Run task immediately from CLI (synchronous)
|
||||
php artisan rsx:task:run Service_Class method_name
|
||||
|
||||
# Run with parameters
|
||||
php artisan rsx:task:run Service_Class method_name '{"param":"value"}'
|
||||
```
|
||||
|
||||
### Programmatic Dispatch
|
||||
|
||||
```php
|
||||
// Dispatch task to queue (async)
|
||||
use App\RSpade\Core\Task\Task;
|
||||
|
||||
Task::dispatch('Service_Class', 'method_name', ['param' => 'value']);
|
||||
|
||||
// Dispatch with delay
|
||||
Task::dispatch('Service_Class', 'method_name', ['param' => 'value'], 60); // 60 second delay
|
||||
```
|
||||
|
||||
### Scheduled Execution
|
||||
|
||||
Tasks with `#[Schedule]` attribute run automatically via cron.
|
||||
|
||||
Add to crontab:
|
||||
```cron
|
||||
* * * * * cd /var/www/html && php artisan rsx:task:process
|
||||
```
|
||||
|
||||
This runs every minute and:
|
||||
1. Processes any queued tasks
|
||||
2. Runs scheduled tasks that are due
|
||||
|
||||
## Cron Expression Syntax
|
||||
|
||||
```
|
||||
┌───────────── minute (0 - 59)
|
||||
│ ┌───────────── hour (0 - 23)
|
||||
│ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday)
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
* * * * *
|
||||
```
|
||||
|
||||
Common patterns:
|
||||
- `*/5 * * * *` - Every 5 minutes
|
||||
- `*/30 * * * *` - Every 30 minutes
|
||||
- `0 * * * *` - Every hour
|
||||
- `0 2 * * *` - Daily at 2 AM
|
||||
- `0 */6 * * *` - Every 6 hours
|
||||
- `0 0 * * 0` - Weekly on Sunday at midnight
|
||||
- `0 0 1 * *` - Monthly on the 1st at midnight
|
||||
- `30 2 * * 1-5` - Weekdays at 2:30 AM
|
||||
|
||||
## Task Queue
|
||||
|
||||
Tasks dispatched via `Task::dispatch()` are stored in the database queue:
|
||||
|
||||
```sql
|
||||
task_queue
|
||||
├── id (bigint)
|
||||
├── service_class (varchar)
|
||||
├── method_name (varchar)
|
||||
├── parameters (json)
|
||||
├── status (enum: pending, processing, completed, failed)
|
||||
├── attempts (int)
|
||||
├── run_at (timestamp)
|
||||
├── started_at (timestamp)
|
||||
├── completed_at (timestamp)
|
||||
├── error_message (text)
|
||||
└── timestamps
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Tasks that throw exceptions:
|
||||
1. Are marked as `failed` in the queue
|
||||
2. Error message is logged
|
||||
3. Can be retried manually via CLI
|
||||
4. Maximum 3 automatic retry attempts
|
||||
|
||||
```php
|
||||
public static function risky_task(Task_Instance $task, array $params = [])
|
||||
{
|
||||
try {
|
||||
// Risky operation
|
||||
$result = External_API::call();
|
||||
} catch (Exception $e) {
|
||||
$task->error("API call failed: " . $e->getMessage());
|
||||
throw $e; // Re-throw to mark task as failed
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep tasks idempotent** - Safe to run multiple times
|
||||
2. **Log progress** for long-running tasks
|
||||
3. **Return meaningful data** for debugging
|
||||
4. **Use transactions** for database operations
|
||||
5. **Set appropriate schedules** to avoid overlap
|
||||
6. **Handle exceptions gracefully**
|
||||
7. **Keep tasks focused** - one task, one purpose
|
||||
|
||||
## Common Task Patterns
|
||||
|
||||
### Cleanup Task
|
||||
```php
|
||||
#[Task('Clean old logs')]
|
||||
#[Schedule('0 3 * * *')] // Daily at 3 AM
|
||||
public static function cleanup_logs(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$cutoff = now()->subDays(30);
|
||||
$deleted = Log_Model::where('created_at', '<', $cutoff)->delete();
|
||||
|
||||
$task->info("Deleted {$deleted} old log entries");
|
||||
|
||||
return ['deleted' => $deleted];
|
||||
}
|
||||
```
|
||||
|
||||
### Import Task
|
||||
```php
|
||||
#[Task('Import data from CSV')]
|
||||
public static function import_csv(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$file_path = $params['file_path'] ?? null;
|
||||
|
||||
if (!$file_path || !file_exists($file_path)) {
|
||||
throw new Exception("File not found: {$file_path}");
|
||||
}
|
||||
|
||||
$handle = fopen($file_path, 'r');
|
||||
$row_count = 0;
|
||||
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
$row_count++;
|
||||
$task->progress($row_count);
|
||||
|
||||
// Process row
|
||||
Model::create([
|
||||
'field1' => $data[0],
|
||||
'field2' => $data[1],
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
$task->info("Imported {$row_count} rows");
|
||||
|
||||
return ['imported' => $row_count];
|
||||
}
|
||||
```
|
||||
|
||||
### Report Generation
|
||||
```php
|
||||
#[Task('Generate monthly report')]
|
||||
#[Schedule('0 0 1 * *')] // First day of month at midnight
|
||||
public static function monthly_report(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$start = now()->subMonth()->startOfMonth();
|
||||
$end = now()->subMonth()->endOfMonth();
|
||||
|
||||
$task->info("Generating report for {$start->format('F Y')}");
|
||||
|
||||
$data = Sales_Model::whereBetween('created_at', [$start, $end])
|
||||
->get();
|
||||
|
||||
$report = Report_Generator::create($data);
|
||||
$report->save(storage_path("reports/monthly-{$start->format('Y-m')}.pdf"));
|
||||
|
||||
$task->info("Report generated successfully");
|
||||
|
||||
return [
|
||||
'period' => $start->format('F Y'),
|
||||
'records' => count($data),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring Tasks
|
||||
|
||||
View task history and status:
|
||||
|
||||
```bash
|
||||
# View pending tasks
|
||||
php artisan rsx:task:pending
|
||||
|
||||
# View failed tasks
|
||||
php artisan rsx:task:failed
|
||||
|
||||
# Retry failed task
|
||||
php artisan rsx:task:retry {task_id}
|
||||
|
||||
# Clear completed tasks older than 30 days
|
||||
php artisan rsx:task:clear --days=30
|
||||
```
|
||||
|
||||
## Service Discovery
|
||||
|
||||
The manifest system automatically discovers all services extending `Rsx_Service_Abstract` in directories configured for scanning. Ensure your service directory is included in the manifest's `scan_directories` configuration.
|
||||
202
app/RSpade/Core/Task/Cron_Parser.php
Executable file
202
app/RSpade/Core/Task/Cron_Parser.php
Executable file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Cron_Parser
|
||||
*
|
||||
* Parses cron expressions and calculates next run times.
|
||||
* Supports standard cron syntax: minute hour day month weekday
|
||||
*
|
||||
* Examples:
|
||||
* Every minute: "* * * * *"
|
||||
* Every hour at minute 0: "0 * * * *"
|
||||
* Daily at midnight: "0 0 * * *"
|
||||
* Daily at 3 AM: "0 3 * * *"
|
||||
* Weekly on Sunday at midnight: "0 0 * * 0"
|
||||
* Monthly on the 1st at midnight: "0 0 1 * *"
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Cron_Parser
|
||||
{
|
||||
private array $parts;
|
||||
private string $expression;
|
||||
|
||||
/**
|
||||
* Parse a cron expression
|
||||
*
|
||||
* @param string $expression Cron expression (minute hour day month weekday)
|
||||
* @throws \InvalidArgumentException If expression is invalid
|
||||
*/
|
||||
public function __construct(string $expression)
|
||||
{
|
||||
$this->expression = $expression;
|
||||
$this->parts = $this->parse($expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cron expression into components
|
||||
*
|
||||
* @param string $expression
|
||||
* @return array ['minute' => [...], 'hour' => [...], 'day' => [...], 'month' => [...], 'weekday' => [...]]
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
private function parse(string $expression): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($expression));
|
||||
|
||||
if (count($parts) !== 5) {
|
||||
throw new \InvalidArgumentException("Invalid cron expression: {$expression}. Expected 5 parts (minute hour day month weekday)");
|
||||
}
|
||||
|
||||
return [
|
||||
'minute' => $this->parse_field($parts[0], 0, 59),
|
||||
'hour' => $this->parse_field($parts[1], 0, 23),
|
||||
'day' => $this->parse_field($parts[2], 1, 31),
|
||||
'month' => $this->parse_field($parts[3], 1, 12),
|
||||
'weekday' => $this->parse_field($parts[4], 0, 6), // 0 = Sunday
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single cron field
|
||||
*
|
||||
* Supports: asterisk, numbers, step values, ranges, lists
|
||||
*
|
||||
* @param string $field Field value from cron expression
|
||||
* @param int $min Minimum allowed value
|
||||
* @param int $max Maximum allowed value
|
||||
* @return array Array of allowed values
|
||||
*/
|
||||
private function parse_field(string $field, int $min, int $max): array
|
||||
{
|
||||
// Asterisk means all values
|
||||
if ($field === '*') {
|
||||
return range($min, $max);
|
||||
}
|
||||
|
||||
// Step values (e.g., */15)
|
||||
if (preg_match('/^\*\/(\d+)$/', $field, $matches)) {
|
||||
$step = (int) $matches[1];
|
||||
return range($min, $max, $step);
|
||||
}
|
||||
|
||||
// Range (e.g., 1-5)
|
||||
if (preg_match('/^(\d+)-(\d+)$/', $field, $matches)) {
|
||||
$start = (int) $matches[1];
|
||||
$end = (int) $matches[2];
|
||||
|
||||
if ($start < $min || $end > $max || $start > $end) {
|
||||
throw new \InvalidArgumentException("Invalid range in cron expression: {$field}");
|
||||
}
|
||||
|
||||
return range($start, $end);
|
||||
}
|
||||
|
||||
// List (e.g., 1,3,5)
|
||||
if (str_contains($field, ',')) {
|
||||
$values = array_map('intval', explode(',', $field));
|
||||
|
||||
foreach ($values as $value) {
|
||||
if ($value < $min || $value > $max) {
|
||||
throw new \InvalidArgumentException("Invalid value in cron expression: {$value}");
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
// Single value
|
||||
$value = (int) $field;
|
||||
|
||||
if ($value < $min || $value > $max) {
|
||||
throw new \InvalidArgumentException("Invalid value in cron expression: {$value}");
|
||||
}
|
||||
|
||||
return [$value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next run time from a given timestamp
|
||||
*
|
||||
* @param int|null $from_timestamp Start time (default: current time)
|
||||
* @return int Next run timestamp
|
||||
*/
|
||||
public function get_next_run_time(?int $from_timestamp = null): int
|
||||
{
|
||||
if ($from_timestamp === null) {
|
||||
$from_timestamp = time();
|
||||
}
|
||||
|
||||
// Start from the next minute (cron runs at most once per minute)
|
||||
$current = new DateTime('@' . $from_timestamp);
|
||||
$current->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
||||
$current->modify('+1 minute');
|
||||
$current->setTime((int) $current->format('H'), (int) $current->format('i'), 0);
|
||||
|
||||
// Try up to 4 years in the future (should be more than enough)
|
||||
$max_iterations = 60 * 24 * 365 * 4;
|
||||
$iterations = 0;
|
||||
|
||||
while ($iterations < $max_iterations) {
|
||||
if ($this->matches($current)) {
|
||||
return $current->getTimestamp();
|
||||
}
|
||||
|
||||
$current->modify('+1 minute');
|
||||
$iterations++;
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Could not calculate next run time for cron expression: {$this->expression}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a datetime matches the cron expression
|
||||
*
|
||||
* @param DateTime $datetime
|
||||
* @return bool
|
||||
*/
|
||||
private function matches(DateTime $datetime): bool
|
||||
{
|
||||
$minute = (int) $datetime->format('i');
|
||||
$hour = (int) $datetime->format('H');
|
||||
$day = (int) $datetime->format('d');
|
||||
$month = (int) $datetime->format('n');
|
||||
$weekday = (int) $datetime->format('w');
|
||||
|
||||
return in_array($minute, $this->parts['minute'])
|
||||
&& in_array($hour, $this->parts['hour'])
|
||||
&& in_array($day, $this->parts['day'])
|
||||
&& in_array($month, $this->parts['month'])
|
||||
&& in_array($weekday, $this->parts['weekday']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression without creating an instance
|
||||
*
|
||||
* @param string $expression
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_valid(string $expression): bool
|
||||
{
|
||||
try {
|
||||
new self($expression);
|
||||
return true;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original cron expression
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_expression(): string
|
||||
{
|
||||
return $this->expression;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
|
||||
/**
|
||||
* Task - Unified task execution system
|
||||
@@ -92,23 +95,45 @@ class Task
|
||||
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
|
||||
}
|
||||
|
||||
// Call pre_task() if exists
|
||||
if (method_exists($service_class, 'pre_task')) {
|
||||
$pre_result = $service_class::pre_task($params);
|
||||
if ($pre_result !== null) {
|
||||
// pre_task returned something, use that as response
|
||||
return $pre_result;
|
||||
// Create task instance for immediate execution
|
||||
$task_instance = new Task_Instance(
|
||||
$service_class,
|
||||
$rsx_task,
|
||||
$params,
|
||||
'default',
|
||||
true // immediate execution
|
||||
);
|
||||
|
||||
// Mark as started
|
||||
$task_instance->mark_started();
|
||||
|
||||
try {
|
||||
// Call pre_task() if exists
|
||||
if (method_exists($service_class, 'pre_task')) {
|
||||
$pre_result = $service_class::pre_task($task_instance, $params);
|
||||
if ($pre_result !== null) {
|
||||
// pre_task returned something, use that as response
|
||||
$task_instance->mark_completed($pre_result);
|
||||
return $pre_result;
|
||||
}
|
||||
}
|
||||
|
||||
// Call the actual task method
|
||||
$response = $service_class::$rsx_task($task_instance, $params);
|
||||
|
||||
// Mark as completed
|
||||
$task_instance->mark_completed($response);
|
||||
|
||||
// Filter response through JSON encode/decode to remove PHP objects
|
||||
// (similar to Ajax behavior)
|
||||
$filtered_response = json_decode(json_encode($response), true);
|
||||
|
||||
return $filtered_response;
|
||||
} catch (Exception $e) {
|
||||
// Mark as failed
|
||||
$task_instance->mark_failed($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Call the actual task method
|
||||
$response = $service_class::$rsx_task($params);
|
||||
|
||||
// Filter response through JSON encode/decode to remove PHP objects
|
||||
// (similar to Ajax behavior)
|
||||
$filtered_response = json_decode(json_encode($response), true);
|
||||
|
||||
return $filtered_response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,4 +150,195 @@ class Task
|
||||
'result' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a task to the queue for async execution
|
||||
*
|
||||
* Creates a database record for the task and returns the task ID.
|
||||
* Task will be picked up and executed by the task processor (rsx:task:process).
|
||||
*
|
||||
* @param string $rsx_service Service name (e.g., 'Seeder_Service')
|
||||
* @param string $rsx_task Task/method name (e.g., 'seed_clients')
|
||||
* @param array $params Parameters to pass to the task
|
||||
* @param array $options Optional task options:
|
||||
* - 'queue' => Queue name (default: 'default')
|
||||
* - 'scheduled_for' => Timestamp when task should run (default: now)
|
||||
* - 'timeout' => Maximum execution time in seconds (default: from config)
|
||||
* @return int Task ID
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function dispatch(string $rsx_service, string $rsx_task, array $params = [], array $options = []): int
|
||||
{
|
||||
// Get manifest to find service
|
||||
$manifest = Manifest::get_all();
|
||||
$service_class = null;
|
||||
$file_info = null;
|
||||
|
||||
// Search for service class in manifest
|
||||
foreach ($manifest as $file_path => $info) {
|
||||
// Skip non-PHP files or files without classes
|
||||
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if class name matches exactly (without namespace)
|
||||
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
|
||||
|
||||
if ($class_basename === $rsx_service) {
|
||||
$service_class = $info['fqcn'];
|
||||
$file_info = $info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$service_class) {
|
||||
throw new Exception("Service class not found: {$rsx_service}");
|
||||
}
|
||||
|
||||
// Check if class exists
|
||||
if (!class_exists($service_class)) {
|
||||
throw new Exception("Service class does not exist: {$service_class}");
|
||||
}
|
||||
|
||||
// Check if it's a subclass of Rsx_Service_Abstract
|
||||
if (!Manifest::php_is_subclass_of($service_class, Rsx_Service_Abstract::class)) {
|
||||
throw new Exception("Service {$service_class} must extend Rsx_Service_Abstract");
|
||||
}
|
||||
|
||||
// Check if method exists and has Task attribute
|
||||
if (!isset($file_info['public_static_methods'][$rsx_task])) {
|
||||
throw new Exception("Task {$rsx_task} not found in service {$service_class}");
|
||||
}
|
||||
|
||||
$method_info = $file_info['public_static_methods'][$rsx_task];
|
||||
$has_task = false;
|
||||
|
||||
// Check for Task attribute in method metadata
|
||||
if (isset($method_info['attributes'])) {
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
|
||||
$has_task = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$has_task) {
|
||||
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
|
||||
}
|
||||
|
||||
// Create task instance
|
||||
$instance = new Task_Instance(
|
||||
$service_class,
|
||||
$rsx_task,
|
||||
$params,
|
||||
$options['queue'] ?? 'default',
|
||||
false // not immediate
|
||||
);
|
||||
|
||||
// Create database record
|
||||
$data = [
|
||||
'class' => $service_class,
|
||||
'method' => $rsx_task,
|
||||
'queue' => $options['queue'] ?? 'default',
|
||||
'status' => Task_Status::PENDING,
|
||||
'params' => json_encode($params),
|
||||
'scheduled_for' => $options['scheduled_for'] ?? now(),
|
||||
'timeout' => $options['timeout'] ?? config('rsx.tasks.default_timeout'),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$task_id = DB::table('_task_queue')->insertGetId($data);
|
||||
|
||||
return $task_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of a task
|
||||
*
|
||||
* Returns task information including status, logs, result, and error.
|
||||
*
|
||||
* @param int $task_id Task ID
|
||||
* @return array|null Task status data or null if not found
|
||||
*/
|
||||
public static function status(int $task_id): ?array
|
||||
{
|
||||
$row = DB::table('_task_queue')->where('id', $task_id)->first();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'class' => $row->class,
|
||||
'method' => $row->method,
|
||||
'queue' => $row->queue,
|
||||
'status' => $row->status,
|
||||
'params' => json_decode($row->params, true),
|
||||
'result' => json_decode($row->result, true),
|
||||
'logs' => $row->logs ? explode("\n", $row->logs) : [],
|
||||
'error' => $row->error,
|
||||
'scheduled_for' => $row->scheduled_for,
|
||||
'started_at' => $row->started_at,
|
||||
'completed_at' => $row->completed_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scheduled tasks from manifest
|
||||
*
|
||||
* Scans the manifest for methods with #[Schedule] attribute
|
||||
* and returns information about each scheduled task.
|
||||
*
|
||||
* @return array Array of scheduled task definitions
|
||||
*/
|
||||
public static function get_scheduled_tasks(): array
|
||||
{
|
||||
$manifest = Manifest::get_all();
|
||||
$scheduled_tasks = [];
|
||||
|
||||
foreach ($manifest as $file_path => $info) {
|
||||
// Skip non-PHP files or files without classes
|
||||
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a service class
|
||||
if (!isset($info['public_static_methods'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($info['public_static_methods'] as $method_name => $method_info) {
|
||||
// Check for Schedule attribute
|
||||
if (!isset($method_info['attributes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Schedule' || str_ends_with($attr_name, '\\Schedule')) {
|
||||
// Found a scheduled task
|
||||
foreach ($attr_instances as $attr_instance) {
|
||||
$cron_expression = $attr_instance[0] ?? null;
|
||||
$queue = $attr_instance[1] ?? 'scheduled';
|
||||
|
||||
if ($cron_expression) {
|
||||
$scheduled_tasks[] = [
|
||||
'class' => $info['fqcn'],
|
||||
'method' => $method_name,
|
||||
'cron_expression' => $cron_expression,
|
||||
'queue' => $queue,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $scheduled_tasks;
|
||||
}
|
||||
}
|
||||
|
||||
446
app/RSpade/Core/Task/Task_Instance.php
Executable file
446
app/RSpade/Core/Task/Task_Instance.php
Executable file
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
|
||||
/**
|
||||
* Task_Instance
|
||||
*
|
||||
* Represents a single task execution instance with logging, status tracking,
|
||||
* and temp directory management. Passed to all task methods for tracking.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Task_Instance
|
||||
{
|
||||
private ?int $id;
|
||||
private string $class;
|
||||
private string $method;
|
||||
private string $queue;
|
||||
private array $params;
|
||||
private Task_Status $status;
|
||||
private array $logs = [];
|
||||
private ?string $temp_dir = null;
|
||||
private bool $is_immediate;
|
||||
|
||||
/**
|
||||
* Create a new task instance
|
||||
*
|
||||
* @param string $class Fully qualified class name
|
||||
* @param string $method Static method name
|
||||
* @param array $params Task parameters
|
||||
* @param string $queue Queue name
|
||||
* @param bool $is_immediate True for immediate execution, false for database-backed
|
||||
*/
|
||||
public function __construct(
|
||||
string $class,
|
||||
string $method,
|
||||
array $params = [],
|
||||
string $queue = 'default',
|
||||
bool $is_immediate = true
|
||||
) {
|
||||
$this->class = $class;
|
||||
$this->method = $method;
|
||||
$this->params = $params;
|
||||
$this->queue = $queue;
|
||||
$this->is_immediate = $is_immediate;
|
||||
$this->status = new Task_Status(Task_Status::PENDING);
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load task instance from database by ID
|
||||
*
|
||||
* @param int $id Task ID
|
||||
* @return self|null
|
||||
*/
|
||||
public static function find(int $id): ?self
|
||||
{
|
||||
$row = DB::table('_task_queue')->where('id', $id)->first();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$instance = new self(
|
||||
$row->class,
|
||||
$row->method,
|
||||
json_decode($row->params, true) ?? [],
|
||||
$row->queue,
|
||||
false
|
||||
);
|
||||
|
||||
$instance->id = $row->id;
|
||||
$instance->status = new Task_Status($row->status);
|
||||
$instance->logs = $row->logs ? explode("\n", $row->logs) : [];
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save task instance to database
|
||||
*
|
||||
* @return int Task ID
|
||||
*/
|
||||
public function save(): int
|
||||
{
|
||||
if ($this->is_immediate) {
|
||||
throw new Exception("Cannot save immediate task to database");
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class' => $this->class,
|
||||
'method' => $this->method,
|
||||
'queue' => $this->queue,
|
||||
'status' => $this->status->value(),
|
||||
'params' => json_encode($this->params),
|
||||
'logs' => implode("\n", $this->logs),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($this->id === null) {
|
||||
$data['created_at'] = now();
|
||||
$this->id = DB::table('_task_queue')->insertGetId($data);
|
||||
} else {
|
||||
DB::table('_task_queue')->where('id', $this->id)->update($data);
|
||||
}
|
||||
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status in database
|
||||
*
|
||||
* @param Task_Status $status New status
|
||||
*/
|
||||
public function update_status(Task_Status $status): void
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update(['status' => $status->value(), 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as started
|
||||
*/
|
||||
public function mark_started(): void
|
||||
{
|
||||
$this->update_status(new Task_Status(Task_Status::RUNNING));
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'started_at' => now(),
|
||||
'worker_pid' => getmypid(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as completed
|
||||
*
|
||||
* @param mixed $result Optional result data
|
||||
*/
|
||||
public function mark_completed($result = null): void
|
||||
{
|
||||
$this->update_status(new Task_Status(Task_Status::COMPLETED));
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
$update = [
|
||||
'completed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($result !== null) {
|
||||
$update['result'] = json_encode($result);
|
||||
}
|
||||
|
||||
DB::table('_task_queue')->where('id', $this->id)->update($update);
|
||||
}
|
||||
|
||||
$this->cleanup_temp_dir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as failed
|
||||
*
|
||||
* @param string $error Error message
|
||||
*/
|
||||
public function mark_failed(string $error): void
|
||||
{
|
||||
$this->update_status(new Task_Status(Task_Status::FAILED));
|
||||
$this->error("Task failed: {$error}");
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'error' => $error,
|
||||
'completed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->cleanup_temp_dir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log message
|
||||
*
|
||||
* @param string $level Log level (info, error, debug)
|
||||
* @param string $message Log message
|
||||
*/
|
||||
public function log(string $level, string $message): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$log_line = "[{$timestamp}] [{$level}] {$message}";
|
||||
$this->logs[] = $log_line;
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'logs' => implode("\n", $this->logs),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
public function info(string $message): void
|
||||
{
|
||||
$this->log('info', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
public function error(string $message): void
|
||||
{
|
||||
$this->log('error', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
public function debug(string $message): void
|
||||
{
|
||||
$this->log('debug', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task progress percentage
|
||||
*
|
||||
* @param int $percent Progress percentage (0-100)
|
||||
* @param string|null $message Optional progress message
|
||||
*/
|
||||
public function update_progress(int $percent, ?string $message = null): void
|
||||
{
|
||||
$percent = max(0, min(100, $percent));
|
||||
|
||||
if ($message) {
|
||||
$this->info("Progress: {$percent}% - {$message}");
|
||||
} else {
|
||||
$this->info("Progress: {$percent}%");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set task result data
|
||||
*
|
||||
* @param mixed $result Result data (will be JSON-encoded)
|
||||
*/
|
||||
public function set_result($result): void
|
||||
{
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'result' => json_encode($result),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat to indicate task is still running
|
||||
*/
|
||||
public function heartbeat(): void
|
||||
{
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'last_heartbeat_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create temporary directory for this task
|
||||
*
|
||||
* @return string Absolute path to temp directory
|
||||
*/
|
||||
public function get_temp_dir(): string
|
||||
{
|
||||
if ($this->temp_dir !== null) {
|
||||
return $this->temp_dir;
|
||||
}
|
||||
|
||||
$base_temp_dir = storage_path('rsx-tmp/tasks');
|
||||
|
||||
if (!is_dir($base_temp_dir)) {
|
||||
mkdir($base_temp_dir, 0755, true);
|
||||
}
|
||||
|
||||
if ($this->is_immediate) {
|
||||
$dir_name = 'immediate_' . uniqid();
|
||||
} else {
|
||||
$dir_name = 'task_' . $this->id;
|
||||
}
|
||||
|
||||
$this->temp_dir = $base_temp_dir . '/' . $dir_name;
|
||||
|
||||
if (!is_dir($this->temp_dir)) {
|
||||
mkdir($this->temp_dir, 0755, true);
|
||||
}
|
||||
|
||||
return $this->temp_dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary directory
|
||||
*/
|
||||
public function cleanup_temp_dir(): void
|
||||
{
|
||||
if ($this->temp_dir === null || !is_dir($this->temp_dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->delete_directory_recursive($this->temp_dir);
|
||||
$this->temp_dir = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete directory and contents
|
||||
*
|
||||
* @param string $dir Directory path
|
||||
*/
|
||||
private function delete_directory_recursive(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->delete_directory_recursive($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task ID
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function get_id(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task class name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_class(): string
|
||||
{
|
||||
return $this->class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task method name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_method(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task parameters
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_params(): array
|
||||
{
|
||||
return $this->params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task queue name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_queue(): string
|
||||
{
|
||||
return $this->queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task status
|
||||
*
|
||||
* @return Task_Status
|
||||
*/
|
||||
public function get_status(): Task_Status
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all task logs
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_logs(): array
|
||||
{
|
||||
return $this->logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task is immediate (not database-backed)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_immediate(): bool
|
||||
{
|
||||
return $this->is_immediate;
|
||||
}
|
||||
}
|
||||
110
app/RSpade/Core/Task/Task_Lock.php
Executable file
110
app/RSpade/Core/Task/Task_Lock.php
Executable file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Task_Lock
|
||||
*
|
||||
* Manages MySQL advisory locks for atomic task queue operations.
|
||||
* Uses GET_LOCK() and RELEASE_LOCK() for distributed locking.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Task_Lock
|
||||
{
|
||||
private string $lock_name;
|
||||
private int $timeout;
|
||||
private bool $is_locked = false;
|
||||
|
||||
/**
|
||||
* Create a new task lock instance
|
||||
*
|
||||
* @param string $lock_name Unique lock identifier
|
||||
* @param int $timeout Lock timeout in seconds (default: 10)
|
||||
*/
|
||||
public function __construct(string $lock_name, int $timeout = 10)
|
||||
{
|
||||
$this->lock_name = $lock_name;
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the lock
|
||||
*
|
||||
* @return bool True if lock acquired, false if already held by another process
|
||||
*/
|
||||
public function acquire(): bool
|
||||
{
|
||||
if ($this->is_locked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = DB::selectOne("SELECT GET_LOCK(?, ?) as acquired", [
|
||||
$this->lock_name,
|
||||
$this->timeout,
|
||||
]);
|
||||
|
||||
$this->is_locked = (bool) $result->acquired;
|
||||
|
||||
return $this->is_locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock
|
||||
*
|
||||
* @return bool True if lock was released, false if lock was not held
|
||||
*/
|
||||
public function release(): bool
|
||||
{
|
||||
if (!$this->is_locked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = DB::selectOne("SELECT RELEASE_LOCK(?) as released", [
|
||||
$this->lock_name,
|
||||
]);
|
||||
|
||||
$was_released = (bool) $result->released;
|
||||
|
||||
if ($was_released) {
|
||||
$this->is_locked = false;
|
||||
}
|
||||
|
||||
return $was_released;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the lock is currently held by this instance
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_locked(): bool
|
||||
{
|
||||
return $this->is_locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the lock is currently held by ANY process
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_in_use(): bool
|
||||
{
|
||||
$result = DB::selectOne("SELECT IS_USED_LOCK(?) as in_use", [
|
||||
$this->lock_name,
|
||||
]);
|
||||
|
||||
return $result->in_use !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically release lock on destruction
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
if ($this->is_locked) {
|
||||
$this->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/RSpade/Core/Task/Task_Status.php
Executable file
71
app/RSpade/Core/Task/Task_Status.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
/**
|
||||
* Task_Status
|
||||
*
|
||||
* Value object representing task execution status.
|
||||
* Provides type-safe status constants and helper methods.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Task_Status
|
||||
{
|
||||
// Status constants
|
||||
public const PENDING = 'pending';
|
||||
public const RUNNING = 'running';
|
||||
public const COMPLETED = 'completed';
|
||||
public const FAILED = 'failed';
|
||||
public const STUCK = 'stuck';
|
||||
|
||||
private string $status;
|
||||
|
||||
public function __construct(string $status)
|
||||
{
|
||||
if (!in_array($status, [self::PENDING, self::RUNNING, self::COMPLETED, self::FAILED, self::STUCK])) {
|
||||
throw new \InvalidArgumentException("Invalid task status: {$status}");
|
||||
}
|
||||
|
||||
$this->status = $status;
|
||||
}
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function is_pending(): bool
|
||||
{
|
||||
return $this->status === self::PENDING;
|
||||
}
|
||||
|
||||
public function is_running(): bool
|
||||
{
|
||||
return $this->status === self::RUNNING;
|
||||
}
|
||||
|
||||
public function is_completed(): bool
|
||||
{
|
||||
return $this->status === self::COMPLETED;
|
||||
}
|
||||
|
||||
public function is_failed(): bool
|
||||
{
|
||||
return $this->status === self::FAILED;
|
||||
}
|
||||
|
||||
public function is_stuck(): bool
|
||||
{
|
||||
return $this->status === self::STUCK;
|
||||
}
|
||||
|
||||
public function is_terminal(): bool
|
||||
{
|
||||
return $this->is_completed() || $this->is_failed();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
}
|
||||
10
app/RSpade/Core/constants.php
Executable file
10
app/RSpade/Core/constants.php
Executable file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* RSpade Framework Constants
|
||||
*
|
||||
* Global constants used throughout the framework
|
||||
*/
|
||||
|
||||
// SPA view name
|
||||
define('SPA', 'Spa_App');
|
||||
Reference in New Issue
Block a user