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:
root
2025-11-19 17:48:15 +00:00
parent 77b4d10af8
commit 9ebcc359ae
4360 changed files with 37751 additions and 18578 deletions

View File

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

View File

@@ -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

View File

@@ -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',
],
];
}

View File

@@ -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);
}

View File

@@ -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
*

View File

@@ -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);
}

View 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
View 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`

View File

@@ -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
// ============================================================================================

View File

@@ -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
*

View File

@@ -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

View File

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

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

View 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)

View File

@@ -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

View File

@@ -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);

View File

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

View File

@@ -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
View 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);
}
}
}

View File

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

View File

@@ -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" .

View File

@@ -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(

View File

@@ -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 = []) {

View File

@@ -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
View 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
View 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`);
}
}

View 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);
}
}

View 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>

View 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
View 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!');
}
}

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

View File

@@ -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

View File

@@ -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.

View 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
View 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.

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

View File

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

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

View 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();
}
}
}

View 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
View File

@@ -0,0 +1,10 @@
<?php
/**
* RSpade Framework Constants
*
* Global constants used throughout the framework
*/
// SPA view name
define('SPA', 'Spa_App');