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:
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_ Database type hints - do not edit manually
|
||||
* Generated on: 2025-09-28 10:36:08
|
||||
* Table: flash_alerts
|
||||
*
|
||||
* @property int $id
|
||||
* @property mixed $session_id
|
||||
* @property string $message
|
||||
* @property mixed $class_attribute
|
||||
* @property string $created_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property string $updated_at
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class FlashAlert extends Rsx_Model_Abstract
|
||||
{
|
||||
// Required static properties from parent abstract class
|
||||
public static $enums = [];
|
||||
public static $rel = [];
|
||||
|
||||
protected $table = 'flash_alerts';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -64,7 +64,7 @@ The Code Quality system is a modular, extensible framework for enforcing coding
|
||||
|
||||
1. **JqhtmlInlineScriptRule** (JQHTML-INLINE-01)
|
||||
- Prohibits inline <script> and <style> tags in .jqhtml templates
|
||||
- Enforces component class pattern with Jqhtml_Component
|
||||
- Enforces component class pattern with Component
|
||||
- Requires separate .js and .scss files
|
||||
- Severity: Critical
|
||||
- Runs at manifest-time
|
||||
@@ -187,15 +187,171 @@ php artisan rsx:check rsx/
|
||||
php artisan rsx:check app/Models/User.php
|
||||
```
|
||||
|
||||
### Exception Comments
|
||||
### Exception Granting System
|
||||
|
||||
Add exception comments to bypass specific violations:
|
||||
The code quality system supports granting exceptions to allow specific violations when justified. Exceptions are granted via specially formatted comments in the source files.
|
||||
|
||||
#### Exception Comment Format
|
||||
|
||||
```
|
||||
@{RULE-ID}-EXCEPTION - Optional rationale
|
||||
```
|
||||
|
||||
**Naming Convention:**
|
||||
- Use the exact rule ID from `get_id()` method
|
||||
- Add `-EXCEPTION` suffix
|
||||
- Examples: `@PHP-NAMING-01-EXCEPTION`, `@JS-DEFENSIVE-01-EXCEPTION`, `@FILE-CASE-01-EXCEPTION`
|
||||
|
||||
#### Exception Placement
|
||||
|
||||
**Line-Level Exceptions** (most common):
|
||||
Place the exception comment on the same line as the violation OR on the line immediately before it.
|
||||
|
||||
```php
|
||||
// @RULE-ID-EXCEPTION (e.g., @PHP-NAMING-01-EXCEPTION)
|
||||
// Code that would normally violate rules
|
||||
// Same-line exception
|
||||
if (key && key.startsWith('rsx::')) { // @JS-DEFENSIVE-01-EXCEPTION - storage.key() can return null
|
||||
|
||||
// Previous-line exception
|
||||
// @PHP-REFLECT-01-EXCEPTION - Test runner needs ReflectionClass for filtering
|
||||
if ($reflection->isAbstract()) {
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
**File-Level Exceptions** (for entire file):
|
||||
Place at the top of the file, after namespace/use statements, before the main docblock.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Ajax;
|
||||
|
||||
use SomeClass;
|
||||
|
||||
// @FILE-SUBCLASS-01-EXCEPTION
|
||||
|
||||
/**
|
||||
* Class docblock
|
||||
*/
|
||||
class MyClass {
|
||||
```
|
||||
|
||||
**Docblock Exceptions** (for method/class):
|
||||
Place inside the docblock using JSDoc/PHPDoc style.
|
||||
|
||||
```php
|
||||
/**
|
||||
* Check if a method is overriding a parent method
|
||||
*
|
||||
* @PHP-REFLECT-02-EXCEPTION: This method needs ReflectionClass to check parent methods
|
||||
* from Laravel framework classes which are not tracked in the manifest.
|
||||
*/
|
||||
protected function is_overriding_parent_method($class_name, $method_name) {
|
||||
```
|
||||
|
||||
#### Implementing Exception Handling in Rules
|
||||
|
||||
**NOT all rules implement exception handling** - it must be added per-rule. Approximately 31 of 111 rules currently support exceptions.
|
||||
|
||||
**To check if a rule supports exceptions:**
|
||||
|
||||
1. Open the rule file in `/system/app/RSpade/CodeQuality/Rules/`
|
||||
2. Search for `EXCEPTION` in the file
|
||||
3. If found, the rule implements exception checking
|
||||
4. If not found, exceptions will be ignored by that rule
|
||||
|
||||
**To implement exception handling in a rule:**
|
||||
|
||||
Add a check before calling `add_violation()`. The exact implementation depends on rule structure:
|
||||
|
||||
**Line-by-line checking pattern:**
|
||||
|
||||
```php
|
||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||
{
|
||||
$lines = explode("\n", $contents);
|
||||
|
||||
foreach ($lines as $line_number => $line) {
|
||||
$actual_line_number = $line_number + 1;
|
||||
|
||||
// Skip if line has exception comment
|
||||
if (str_contains($line, '@' . $this->get_id() . '-EXCEPTION')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for violation
|
||||
if ($this->detect_violation($line)) {
|
||||
$this->add_violation(...);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Multi-line or previous-line pattern:**
|
||||
|
||||
```php
|
||||
// Check previous line for exception comment
|
||||
$prev_line_index = $line_number - 1;
|
||||
if ($prev_line_index >= 0 && str_contains($lines[$prev_line_index], '@' . $this->get_id() . '-EXCEPTION')) {
|
||||
continue; // Skip this line
|
||||
}
|
||||
```
|
||||
|
||||
**File-level pattern:**
|
||||
|
||||
```php
|
||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||
{
|
||||
// Check if entire file has exception
|
||||
if (str_contains($contents, '@' . $this->get_id() . '-EXCEPTION')) {
|
||||
return; // Skip entire file
|
||||
}
|
||||
|
||||
// ... rest of checking logic
|
||||
}
|
||||
```
|
||||
|
||||
#### Exception Rationale Guidelines
|
||||
|
||||
**Always include a rationale** explaining WHY the exception is needed:
|
||||
|
||||
```javascript
|
||||
// @JS-DEFENSIVE-01-EXCEPTION - Browser API storage.key(i) can return null when i >= storage.length
|
||||
```
|
||||
|
||||
**Good rationales:**
|
||||
- Reference external API behavior: "Browser API returns null", "Laravel method signature requires this"
|
||||
- Explain architectural necessity: "Task system uses direct queries for performance"
|
||||
- Note optional/polymorphic patterns: "Array.find() returns undefined when no match"
|
||||
|
||||
**Bad rationales:**
|
||||
- "TODO: fix later"
|
||||
- "Not sure why this is needed"
|
||||
- No rationale at all
|
||||
|
||||
#### CRITICAL: AI Agent Exception Policy
|
||||
|
||||
**ABSOLUTE PROHIBITION - NEVER GRANT EXCEPTIONS AUTONOMOUSLY**
|
||||
|
||||
When you encounter a code quality violation, you are **FORBIDDEN** from granting exceptions without explicit programmer approval.
|
||||
|
||||
**Required procedure:**
|
||||
1. **STOP** - Do not add exception comments
|
||||
2. **ANALYZE** - Determine if the violation is:
|
||||
- Invalid defensive coding (should be removed)
|
||||
- Valid duck typing (needs exception)
|
||||
- External API constraint (needs exception)
|
||||
3. **REPORT** - Present findings: "Found violation X in file Y. Analysis: [your reasoning]. Options: a) Remove the check (fail-loud), b) Grant exception (provide rationale), c) Refactor differently"
|
||||
4. **WAIT** - Wait for programmer to decide
|
||||
5. **NEVER** add `@RULE-ID-EXCEPTION` comments without explicit approval
|
||||
|
||||
**Only grant exceptions when:**
|
||||
- Programmer explicitly requests it: "grant an exception for this"
|
||||
- Programmer approves your recommendation: "yes, add the exception"
|
||||
- You are implementing a fix that the programmer has already approved
|
||||
|
||||
**Exception grants are permanent code changes** - they suppress violations indefinitely and become part of the codebase. Do not make this decision autonomously.
|
||||
|
||||
## Development
|
||||
|
||||
### Creating New Rules
|
||||
|
||||
@@ -297,7 +297,7 @@ Companion JavaScript ({$js_path}):
|
||||
/**
|
||||
* {$class_name} jqhtml component
|
||||
*/
|
||||
class {$class_name} extends Jqhtml_Component {
|
||||
class {$class_name} extends Component {
|
||||
on_create() {
|
||||
// Initialize component state
|
||||
this.data.count = 0;
|
||||
|
||||
@@ -233,18 +233,18 @@ class HardcodedInternalUrl_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
if ($is_jqhtml) {
|
||||
// JavaScript version for .jqhtml files using <%= %> syntax
|
||||
if (empty($params)) {
|
||||
return "<%= Rsx.Route('{$class_name}', '{$method_name}') %>";
|
||||
return "<%= Rsx.Route('{$class_name}::{$method_name}') %>";
|
||||
} else {
|
||||
$params_json = json_encode($params, JSON_UNESCAPED_SLASHES);
|
||||
return "<%= Rsx.Route('{$class_name}', '{$method_name}', {$params_json}) %>";
|
||||
return "<%= Rsx.Route('{$class_name}::{$method_name}', {$params_json}) %>";
|
||||
}
|
||||
} else {
|
||||
// PHP version for .blade.php files
|
||||
if (empty($params)) {
|
||||
return "{{ Rsx::Route('{$class_name}', '{$method_name}') }}";
|
||||
return "{{ Rsx::Route('{$class_name}::{$method_name}') }}";
|
||||
} else {
|
||||
$params_str = $this->_format_php_array($params);
|
||||
return "{{ Rsx::Route('{$class_name}', '{$method_name}', {$params_str}) }}";
|
||||
return "{{ Rsx::Route('{$class_name}::{$method_name}', {$params_str}) }}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||
*
|
||||
* Example violations:
|
||||
* - Rsx::Route('Controller') // PHP
|
||||
* - Rsx.Route('Controller', 'index') // JavaScript
|
||||
* - Rsx.Route('Controller::index') // JavaScript
|
||||
*
|
||||
* Correct usage:
|
||||
* - Rsx::Route('Controller') // PHP
|
||||
|
||||
@@ -86,16 +86,36 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
*/
|
||||
private function route_exists(string $controller, string $method): bool
|
||||
{
|
||||
// First check if this is a SPA action (JavaScript class extending Spa_Action)
|
||||
if ($this->is_spa_action($controller)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise check PHP routes
|
||||
try {
|
||||
// Use the same validation logic as Rsx::Route()
|
||||
// If this doesn't throw an exception, the route exists
|
||||
\App\RSpade\Core\Rsx::Route($controller, $method);
|
||||
\App\RSpade\Core\Rsx::Route($controller . '::' . $method);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a controller name is actually a SPA action class
|
||||
*/
|
||||
private function is_spa_action(string $class_name): bool
|
||||
{
|
||||
try {
|
||||
// Use manifest to check if this JavaScript class extends Spa_Action
|
||||
return \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Spa_Action');
|
||||
} catch (\Exception $e) {
|
||||
// If manifest not available, class doesn't exist, or any error, return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check a file for violations
|
||||
@@ -114,9 +134,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Pattern to match Rsx::Route and Rsx.Route calls (NOT plain Route())
|
||||
// Matches both single and double parameter versions:
|
||||
// - Rsx::Route('Controller') // PHP, defaults to 'index'
|
||||
// - Rsx::Route('Controller', 'method') // PHP
|
||||
// - Rsx::Route('Controller::method') // PHP
|
||||
// - Rsx.Route('Controller') // JavaScript, defaults to 'index'
|
||||
// - Rsx.Route('Controller', 'method') // JavaScript
|
||||
// - Rsx.Route('Controller::method') // JavaScript
|
||||
|
||||
// Pattern for two parameters
|
||||
$pattern_two_params = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/';
|
||||
@@ -190,11 +210,24 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
|
||||
// Extract controller from ORIGINAL content, not sanitized
|
||||
$controller = $original_matches[1][$index][0] ?? $matches[1][$index][0];
|
||||
$method = 'index'; // Default to 'index'
|
||||
$controller_string = $original_matches[1][$index][0] ?? $matches[1][$index][0];
|
||||
|
||||
// Check if using Controller::method syntax
|
||||
if (str_contains($controller_string, '::')) {
|
||||
[$controller, $method] = explode('::', $controller_string, 2);
|
||||
} else {
|
||||
$controller = $controller_string;
|
||||
$method = 'index'; // Default to 'index'
|
||||
}
|
||||
|
||||
// Skip if contains template variables like {$variable}
|
||||
if (str_contains($controller, '{$') || str_contains($controller, '${')) {
|
||||
if (str_contains($controller, '{$') || str_contains($controller, '${') ||
|
||||
str_contains($method, '{$') || str_contains($method, '${')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if method starts with '#' - indicates unimplemented route
|
||||
if (str_starts_with($method, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -240,10 +273,10 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
$suggestions[] = " - Create the controller if it doesn't exist";
|
||||
$suggestions[] = " - Add the method with a #[Route] attribute";
|
||||
$suggestions[] = "3. Use '#' prefix for unimplemented routes (recommended):";
|
||||
$suggestions[] = " - Use Rsx::Route('Controller', '#index') for unimplemented index methods";
|
||||
$suggestions[] = " - Use Rsx::Route('Controller', '#method_name') for other unimplemented methods";
|
||||
$suggestions[] = " - Use Rsx::Route('Controller::#index') for unimplemented index methods";
|
||||
$suggestions[] = " - Use Rsx::Route('Controller::#method_name') for other unimplemented methods";
|
||||
$suggestions[] = " - Routes with '#' prefix will generate '#' URLs and bypass this validation";
|
||||
$suggestions[] = " - Example: Rsx::Route('Backend_Users_Controller', '#index')";
|
||||
$suggestions[] = " - Example: Rsx::Route('Backend_Users_Controller::#index')";
|
||||
|
||||
return implode("\n", $suggestions);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class SubclassNaming_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
|
||||
// Also check if the parent of parent is exempt - for deeper inheritance
|
||||
// E.g., if Widget extends Jqhtml_Component, and Dynamic_Widget extends Widget
|
||||
// E.g., if Widget extends Component, and Dynamic_Widget extends Widget
|
||||
// Dynamic_Widget should match Widget's suffix
|
||||
$parent_of_parent = $this->get_parent_class($parent_class_simple);
|
||||
if ($parent_of_parent && !in_array($parent_of_parent, $suffix_exempt_classes)) {
|
||||
|
||||
@@ -53,7 +53,12 @@ class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
|
||||
foreach ($lines as $line_num => $line) {
|
||||
$line_number = $line_num + 1;
|
||||
|
||||
|
||||
// Skip if line has exception comment
|
||||
if (str_contains($line, '@' . $this->get_id() . '-EXCEPTION')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip comments
|
||||
$trimmed_line = trim($line);
|
||||
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
|
||||
@@ -174,7 +179,7 @@ class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
" if (result && result.redirect) // OK - result may have optional redirect\n" .
|
||||
" if (error && error.message) // OK - polymorphic error handling\n" .
|
||||
" if (options && options.callback) // OK - optional configuration\n\n" .
|
||||
"NOTE: Core guaranteed classes (Rsx, Modal, Jqhtml_Component, etc.) should NEVER be checked - let failures happen loudly during development.";
|
||||
"NOTE: Core guaranteed classes (Rsx, Modal, Component, etc.) should NEVER be checked - let failures happen loudly during development.";
|
||||
|
||||
$this->add_violation(
|
||||
$file_path,
|
||||
|
||||
@@ -39,7 +39,7 @@ class DirectAjaxApi_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
* Should use:
|
||||
* await Controller.action(params)
|
||||
* Or:
|
||||
* await Ajax.call(Rsx.Route('Controller', 'action'), params)
|
||||
* await Ajax.call(Rsx.Route('Controller::action'), params)
|
||||
*/
|
||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
|
||||
use App\RSpade\Core\Cache\RsxCache;
|
||||
|
||||
/**
|
||||
* Check Jqhtml_Component implementations for common AI agent mistakes
|
||||
* Check Component implementations for common AI agent mistakes
|
||||
* Validates that components follow correct patterns
|
||||
*/
|
||||
class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
@@ -24,7 +24,7 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
|
||||
|
||||
public function get_description(): string
|
||||
{
|
||||
return 'Validates Jqhtml_Component subclasses follow correct patterns';
|
||||
return 'Validates Component subclasses follow correct patterns';
|
||||
}
|
||||
|
||||
public function get_file_patterns(): array
|
||||
@@ -47,8 +47,8 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not a Jqhtml_Component subclass
|
||||
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Jqhtml_Component') {
|
||||
// Skip if not a Component subclass
|
||||
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Component') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,10 +91,10 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
|
||||
$error_message .= "Class: {$class_name}\n\n";
|
||||
$error_message .= "Jqhtml components should not define a render() method.\n\n";
|
||||
$error_message .= "PROBLEM:\n";
|
||||
$error_message .= "The render() method is not part of the Jqhtml_Component lifecycle.\n";
|
||||
$error_message .= "The render() method is not part of the Component lifecycle.\n";
|
||||
$error_message .= "Jqhtml components use template files (.jqhtml) for rendering.\n\n";
|
||||
$error_message .= "INCORRECT:\n";
|
||||
$error_message .= " class My_Component extends Jqhtml_Component {\n";
|
||||
$error_message .= " class My_Component extends Component {\n";
|
||||
$error_message .= " render() {\n";
|
||||
$error_message .= " return '<div>...</div>';\n";
|
||||
$error_message .= " }\n";
|
||||
@@ -105,7 +105,7 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
|
||||
$error_message .= " <%= content() %>\n";
|
||||
$error_message .= " </div>\n\n";
|
||||
$error_message .= " // JavaScript class handles logic:\n";
|
||||
$error_message .= " class My_Component extends Jqhtml_Component {\n";
|
||||
$error_message .= " class My_Component extends Component {\n";
|
||||
$error_message .= " on_ready() {\n";
|
||||
$error_message .= " // Component logic here\n";
|
||||
$error_message .= " }\n";
|
||||
@@ -136,13 +136,13 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
|
||||
$error_message .= "The method '{$method_name}()' should be 'on_{$method_name}()'.\n";
|
||||
$error_message .= "Jqhtml components use specific lifecycle method names.\n\n";
|
||||
$error_message .= "INCORRECT:\n";
|
||||
$error_message .= " class My_Component extends Jqhtml_Component {\n";
|
||||
$error_message .= " class My_Component extends Component {\n";
|
||||
$error_message .= " create() { ... } // Wrong\n";
|
||||
$error_message .= " load() { ... } // Wrong\n";
|
||||
$error_message .= " ready() { ... } // Wrong\n";
|
||||
$error_message .= " }\n\n";
|
||||
$error_message .= "CORRECT:\n";
|
||||
$error_message .= " class My_Component extends Jqhtml_Component {\n";
|
||||
$error_message .= " class My_Component extends Component {\n";
|
||||
$error_message .= " on_create() { ... } // Correct\n";
|
||||
$error_message .= " on_load() { ... } // Correct\n";
|
||||
$error_message .= " on_ready() { ... } // Correct\n";
|
||||
|
||||
@@ -158,7 +158,7 @@ const walk = require('acorn-walk');
|
||||
|
||||
// Classes that are Jqhtml components
|
||||
const JQHTML_COMPONENTS = new Set([
|
||||
'Jqhtml_Component', '_Base_Jqhtml_Component', 'Component'
|
||||
'Component', '_Base_Jqhtml_Component', 'Component'
|
||||
]);
|
||||
|
||||
function analyzeFile(filePath) {
|
||||
@@ -182,7 +182,7 @@ function analyzeFile(filePath) {
|
||||
let currentClass = null;
|
||||
let inOnCreate = false;
|
||||
|
||||
// Helper to check if a class extends Jqhtml_Component
|
||||
// Helper to check if a class extends Component
|
||||
function isJqhtmlComponent(extendsClass) {
|
||||
if (!extendsClass) return false;
|
||||
return JQHTML_COMPONENTS.has(extendsClass) ||
|
||||
|
||||
@@ -66,7 +66,7 @@ class JqhtmlOnLoadData_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Check each class to see if it's a JQHTML component
|
||||
foreach ($js_classes as $class_name) {
|
||||
// Use Manifest to check inheritance (handles indirect inheritance)
|
||||
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
|
||||
if (!Manifest::js_is_subclass_of($class_name, 'Component') &&
|
||||
!Manifest::js_is_subclass_of($class_name, 'Component')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class JqhtmlOnLoadDom_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for DOM access in on_load methods of Jqhtml_Component subclasses
|
||||
* Check for DOM access in on_load methods of Component subclasses
|
||||
*/
|
||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||
{
|
||||
@@ -66,7 +66,7 @@ class JqhtmlOnLoadDom_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Check each class to see if it's a JQHTML component
|
||||
foreach ($js_classes as $class_name) {
|
||||
// Use Manifest to check inheritance (handles indirect inheritance)
|
||||
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
|
||||
if (!Manifest::js_is_subclass_of($class_name, 'Component') &&
|
||||
!Manifest::js_is_subclass_of($class_name, 'Component')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class JqhtmlRenderOverride_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for render() method override in Jqhtml_Component subclasses
|
||||
* Check for render() method override in Component subclasses
|
||||
*/
|
||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||
{
|
||||
@@ -66,7 +66,7 @@ class JqhtmlRenderOverride_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Check each class to see if it's a JQHTML component
|
||||
foreach ($js_classes as $class_name) {
|
||||
// Use Manifest to check inheritance (handles indirect inheritance)
|
||||
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
|
||||
if (!Manifest::js_is_subclass_of($class_name, 'Component') &&
|
||||
!Manifest::js_is_subclass_of($class_name, 'Component')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ class LifecycleMethodsStatic_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Get class name
|
||||
$class_name = $metadata['class'];
|
||||
|
||||
// Only check classes that extend Jqhtml_Component
|
||||
if (!\App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component')) {
|
||||
// Only check classes that extend Component
|
||||
if (!\App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Component')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
$this->check_jqhtml_file($file_path, $contents);
|
||||
}
|
||||
|
||||
// Check .js files for classes extending Jqhtml_Component
|
||||
// Check .js files for classes extending Component
|
||||
if (str_ends_with($file_path, '.js')) {
|
||||
$this->check_javascript_file($file_path, $contents, $metadata);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
|
||||
/**
|
||||
* Check JavaScript files for Jqhtml_Component subclasses
|
||||
* Check JavaScript files for Component subclasses
|
||||
*/
|
||||
private function check_javascript_file(string $file_path, string $contents, array $metadata = []): void
|
||||
{
|
||||
@@ -131,7 +131,7 @@ class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
// Check each class to see if it's a JQHTML component
|
||||
foreach ($class_definitions as $class_name => $line_num) {
|
||||
// Use Manifest to check if this is a JQHTML component (handles indirect inheritance)
|
||||
if (Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component')) {
|
||||
if (Manifest::js_is_subclass_of($class_name, 'Component')) {
|
||||
// Check if first character is not uppercase
|
||||
if (!ctype_upper($class_name[0])) {
|
||||
$this->add_violation(
|
||||
|
||||
@@ -67,10 +67,10 @@ class JqhtmlEventPreventDefault_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first class that extends Jqhtml_Component
|
||||
// Find the first class that extends Component
|
||||
$class_name = null;
|
||||
foreach ($js_classes as $js_class) {
|
||||
if (Manifest::js_is_subclass_of($js_class, 'Jqhtml_Component')) {
|
||||
if (Manifest::js_is_subclass_of($js_class, 'Component')) {
|
||||
$class_name = $js_class;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ class JqhtmlInlineScript_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
REQUIRED STEPS:
|
||||
1. Create a JavaScript file: {$js_path}
|
||||
2. Name the ES6 class exactly: {$class_name}
|
||||
3. Extend Jqhtml_Component base class
|
||||
3. Extend Component base class
|
||||
4. Implement lifecycle methods: on_create(), on_load(), on_ready()
|
||||
|
||||
EXAMPLE IMPLEMENTATION for {$js_filename}:
|
||||
@@ -179,7 +179,7 @@ EXAMPLE IMPLEMENTATION for {$js_filename}:
|
||||
/**
|
||||
* Component class for {$class_name}
|
||||
*/
|
||||
class {$class_name} extends Jqhtml_Component {
|
||||
class {$class_name} extends Component {
|
||||
/**
|
||||
* Called when component instance is created
|
||||
* Use for initial setup and event binding
|
||||
@@ -229,7 +229,7 @@ class {$class_name} extends Jqhtml_Component {
|
||||
|
||||
KEY CONVENTIONS:
|
||||
- Class name MUST match the <Define:{$class_name}> in the .jqhtml file
|
||||
- MUST extend Jqhtml_Component base class
|
||||
- MUST extend Component base class
|
||||
- Use lifecycle methods: on_create(), on_load(), on_ready()
|
||||
- Access component element via this.\$
|
||||
- Bind events using \$onclick, \$onchange, etc. in template
|
||||
|
||||
@@ -92,11 +92,11 @@ class FilenameClassMatch_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
{
|
||||
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
|
||||
|
||||
// Check if this is a JavaScript class extending Jqhtml_Component
|
||||
// Check if this is a JavaScript class extending Component
|
||||
$is_jqhtml_component = false;
|
||||
if ($extension === 'js') {
|
||||
try {
|
||||
$is_jqhtml_component = \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component');
|
||||
$is_jqhtml_component = \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Component');
|
||||
} catch (\Exception $e) {
|
||||
// Class not in manifest or not a JS class, treat as regular class
|
||||
$is_jqhtml_component = false;
|
||||
|
||||
@@ -12,10 +12,10 @@ use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||
* to check if a class extends another, since regex can't detect indirect inheritance.
|
||||
*
|
||||
* Example of incorrect pattern:
|
||||
* preg_match('/class\s+\w+\s+extends\s+Jqhtml_Component/', $contents)
|
||||
* preg_match('/class\s+\w+\s+extends\s+Component/', $contents)
|
||||
*
|
||||
* Should be:
|
||||
* Manifest::php_is_subclass_of($class_name, 'Jqhtml_Component')
|
||||
* Manifest::php_is_subclass_of($class_name, 'Component')
|
||||
*/
|
||||
class Code_Quality_Meta_Inheritance_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
{
|
||||
@@ -92,7 +92,7 @@ class Code_Quality_Meta_Inheritance_CodeQualityRule extends CodeQualityRule_Abst
|
||||
"If checking JavaScript classes: Use Manifest::js_is_subclass_of(\$class_name, 'BaseClass')\n" .
|
||||
"Examples:\n" .
|
||||
" PHP: if (Manifest::php_is_subclass_of(\$metadata['class'], 'Rsx_Model_Abstract')) { ... }\n" .
|
||||
" JS: if (Manifest::js_is_subclass_of(\$class_name, 'Jqhtml_Component')) { ... }",
|
||||
" JS: if (Manifest::js_is_subclass_of(\$class_name, 'Component')) { ... }",
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,11 +87,17 @@ class DbTableUsage_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
if ($table_name === 'migrations') {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Skip Laravel's sessions table - managed by framework
|
||||
if ($table_name === 'sessions') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip framework internal tables (prefixed with underscore)
|
||||
// These are low-level system tables managed directly for performance
|
||||
if (str_starts_with($table_name, '_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use original line for display in error message
|
||||
$original_line = $original_lines[$line_num] ?? $sanitized_line;
|
||||
|
||||
@@ -276,10 +276,10 @@ class HardcodedUrlInRedirect_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
protected function _generate_rsx_suggestion(string $class_name, string $method_name, array $params): string
|
||||
{
|
||||
if (empty($params)) {
|
||||
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}'));";
|
||||
return "return redirect(Rsx::Route('{$class_name}::{$method_name}'));";
|
||||
} else {
|
||||
$params_str = $this->_format_php_array($params);
|
||||
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}', {$params_str}));";
|
||||
return "return redirect(Rsx::Route('{$class_name}::{$method_name}', {$params_str}));";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,9 +141,9 @@ class RouteQueryConcatenation_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
$line,
|
||||
"Query parameters should be passed as the third argument to Rsx::Route() as an array.\n\n" .
|
||||
"WRONG:\n" .
|
||||
" Rsx::Route('Controller', 'method') . '?param=value&other=test'\n\n" .
|
||||
" Rsx::Route('Controller::method') . '?param=value&other=test'\n\n" .
|
||||
"CORRECT:\n" .
|
||||
" Rsx::Route('Controller', 'method', ['param' => 'value', 'other' => 'test'])\n\n" .
|
||||
" Rsx::Route('Controller::method', ['param' => 'value', 'other' => 'test'])\n\n" .
|
||||
"The framework will automatically URL-encode the parameters and construct the query string properly."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ class ManifestErrors
|
||||
$error_message .= "❌ BAD: class MyComponent extends window.BaseComponent {}\n";
|
||||
$error_message .= "✅ GOOD: class MyComponent extends BaseComponent {}\n\n";
|
||||
$error_message .= "❌ BAD: class Widget extends jqhtml.Component {}\n";
|
||||
$error_message .= "✅ GOOD: class Widget extends Jqhtml_Component {}\n\n";
|
||||
$error_message .= "✅ GOOD: class Widget extends Component {}\n\n";
|
||||
$error_message .= "This restriction maintains consistency and enables proper class hierarchy tracking.\n";
|
||||
$error_message .= "==========================================";
|
||||
|
||||
@@ -106,7 +106,7 @@ class ManifestErrors
|
||||
int $line_num,
|
||||
string $class_name
|
||||
): void {
|
||||
$error_message = "Fatal: Incorrect Jqhtml_Component implementation detected.\n\n";
|
||||
$error_message = "Fatal: Incorrect Component implementation detected.\n\n";
|
||||
$error_message .= "File: {$file}\n";
|
||||
$error_message .= "Line {$line_num}: Found render() method\n\n";
|
||||
$error_message .= "PROBLEM: The render() method should not exist in JavaScript component classes.\n\n";
|
||||
@@ -137,10 +137,10 @@ class ManifestErrors
|
||||
int $line_num,
|
||||
string $method
|
||||
): void {
|
||||
$error_message = "Fatal: Incorrect Jqhtml_Component lifecycle method detected.\n\n";
|
||||
$error_message = "Fatal: Incorrect Component lifecycle method detected.\n\n";
|
||||
$error_message .= "File: {$file}\n";
|
||||
$error_message .= "Line {$line_num}: Found '{$method}()' method\n\n";
|
||||
$error_message .= "PROBLEM: Jqhtml_Component lifecycle methods must be prefixed with 'on_'\n\n";
|
||||
$error_message .= "PROBLEM: Component lifecycle methods must be prefixed with 'on_'\n\n";
|
||||
$error_message .= "SOLUTION: Rename the method:\n";
|
||||
$error_message .= "- '{$method}()' should be 'on_{$method}()'\n\n";
|
||||
$error_message .= "CORRECT LIFECYCLE METHODS:\n";
|
||||
|
||||
50
app/RSpade/Commands/Restricted/Session_Cleanup_Command.php
Executable file
50
app/RSpade/Commands/Restricted/Session_Cleanup_Command.php
Executable file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Commands\Restricted;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class Session_Cleanup_Command extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'session:cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '[RESTRICTED] Session cleanup runs automatically via scheduled task';
|
||||
|
||||
/**
|
||||
* Hide this command from artisan list
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $hidden = true;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->error('[RESTRICTED] This command is disabled.');
|
||||
$this->info('');
|
||||
$this->info('Session cleanup runs automatically via scheduled task:');
|
||||
$this->info(' Session_Cleanup_Service::cleanup_sessions()');
|
||||
$this->info(' Schedule: Daily at 3 AM');
|
||||
$this->info('');
|
||||
$this->info('Cleanup rules:');
|
||||
$this->info(' - Logged-in sessions (login_user_id set): Delete if older than 365 days');
|
||||
$this->info(' - Anonymous sessions (login_user_id null): Delete if older than 14 days');
|
||||
$this->info('');
|
||||
$this->info('To manually trigger cleanup:');
|
||||
$this->info(' php artisan rsx:task:run Session_Cleanup_Service cleanup_sessions');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ JQHTML;
|
||||
* 2. on_load() - Fetch data from APIs (parallel execution, no DOM modifications)
|
||||
* 3. on_ready() - Component fully initialized, runs bottom-up through component tree
|
||||
*/
|
||||
class {$class_name} extends Jqhtml_Component {
|
||||
class {$class_name} extends Component {
|
||||
/**
|
||||
* Called after render, quick UI setup (bottom-up)
|
||||
* Use for: Initial state, event bindings, showing loading indicators
|
||||
|
||||
@@ -78,7 +78,7 @@ class RsxStorageCleanupCommand extends FrameworkDeveloperCommand
|
||||
->get();
|
||||
|
||||
// Find orphaned storage
|
||||
$orphaned_storage = DB::table('file_storage')
|
||||
$orphaned_storage = DB::table('_file_storage')
|
||||
->leftJoin('file_attachments', 'file_storage.id', '=', 'file_attachments.file_storage_id')
|
||||
->whereNull('file_attachments.id')
|
||||
->select('file_storage.*')
|
||||
|
||||
@@ -116,7 +116,7 @@ class RsxStorageStatsCommand extends FrameworkDeveloperCommand
|
||||
}
|
||||
|
||||
// Orphaned storage (no attachments)
|
||||
$orphaned_storage = DB::table('file_storage')
|
||||
$orphaned_storage = DB::table('_file_storage')
|
||||
->leftJoin('file_attachments', 'file_storage.id', '=', 'file_attachments.file_storage_id')
|
||||
->whereNull('file_attachments.id')
|
||||
->count();
|
||||
|
||||
@@ -88,29 +88,24 @@ class Rsx_Routes_Command extends Command
|
||||
{
|
||||
$all_routes = [];
|
||||
|
||||
foreach ($routes as $type => $type_routes) {
|
||||
foreach ($type_routes as $pattern => $methods) {
|
||||
foreach ($methods as $method => $handlers) {
|
||||
// Handle new structure where each method can have multiple handlers
|
||||
if (!isset($handlers[0])) {
|
||||
// Old structure - single handler
|
||||
$handlers = [$handlers];
|
||||
}
|
||||
// New unified structure: $routes[$pattern] => route_data
|
||||
foreach ($routes as $pattern => $route_data) {
|
||||
// Each route can support multiple HTTP methods
|
||||
$methods = $route_data['methods'] ?? ['GET'];
|
||||
|
||||
foreach ($handlers as $handler) {
|
||||
$all_routes[] = [
|
||||
'type' => $type,
|
||||
'pattern' => $pattern,
|
||||
'method' => $method,
|
||||
'class' => $handler['class'] ?? 'N/A',
|
||||
'action' => $handler['method'] ?? 'N/A',
|
||||
'middleware' => $this->get_middleware($handler),
|
||||
'cache' => $this->get_cache_ttl($handler),
|
||||
'name' => $handler['name'] ?? null,
|
||||
'attributes' => $handler['attributes'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach ($methods as $method) {
|
||||
$all_routes[] = [
|
||||
'type' => $route_data['type'] ?? 'standard',
|
||||
'pattern' => $pattern,
|
||||
'method' => $method,
|
||||
'class' => $route_data['class'] ?? 'N/A',
|
||||
'action' => $route_data['method'] ?? 'N/A',
|
||||
'middleware' => $this->get_middleware($route_data),
|
||||
'cache' => $this->get_cache_ttl($route_data),
|
||||
'name' => $route_data['name'] ?? null,
|
||||
'file' => $route_data['file'] ?? null,
|
||||
'js_action_class' => $route_data['js_action_class'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,24 +225,23 @@ class Rsx_Routes_Command extends Command
|
||||
// Build key-value pairs
|
||||
$parts[] = 'path=' . $route['pattern'];
|
||||
$parts[] = 'method=' . $route['method'];
|
||||
$parts[] = 'type=' . $route['type'];
|
||||
$parts[] = 'controller=' . class_basename($route['class']);
|
||||
$parts[] = 'action=' . $route['action'];
|
||||
|
||||
// Get file path if we can determine it
|
||||
if ($route['class'] !== 'N/A') {
|
||||
try {
|
||||
$file = Manifest::php_find_class(class_basename($route['class']));
|
||||
if ($file) {
|
||||
$parts[] = 'file=' . $file;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Class not found in manifest, skip file path
|
||||
}
|
||||
// File path is now directly in route data
|
||||
if (!empty($route['file'])) {
|
||||
$parts[] = 'file=' . $route['file'];
|
||||
}
|
||||
|
||||
// Add SPA-specific fields
|
||||
if (!empty($route['js_action_class'])) {
|
||||
$parts[] = 'js_action=' . $route['js_action_class'];
|
||||
}
|
||||
|
||||
// Add optional fields if present
|
||||
if ($route['middleware']) {
|
||||
$parts[] = 'middleware=' . $route['middleware'];
|
||||
$parts[] = 'auth=' . $route['middleware'];
|
||||
}
|
||||
if ($route['cache']) {
|
||||
$parts[] = 'cache=' . $route['cache'] . 's';
|
||||
@@ -260,23 +254,28 @@ class Rsx_Routes_Command extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* Get middleware string from handler
|
||||
* Get middleware string from route data
|
||||
*
|
||||
* @param array $handler
|
||||
* @param array $route_data
|
||||
* @return string|null
|
||||
*/
|
||||
protected function get_middleware($handler)
|
||||
protected function get_middleware($route_data)
|
||||
{
|
||||
$attributes = $handler['attributes'] ?? [];
|
||||
// Check if route has require (Auth) attributes
|
||||
$require = $route_data['require'] ?? [];
|
||||
|
||||
foreach ($attributes as $attr) {
|
||||
if (isset($attr['class']) && str_ends_with($attr['class'], 'Middleware')) {
|
||||
$middleware = $attr['args']['middleware'] ?? $attr['args'][0] ?? null;
|
||||
if (is_array($middleware)) {
|
||||
return implode(',', $middleware);
|
||||
if (!empty($require)) {
|
||||
// Flatten require array (it's an array of arrays)
|
||||
$perms = [];
|
||||
foreach ($require as $req) {
|
||||
if (is_array($req)) {
|
||||
$perms = array_merge($perms, $req);
|
||||
} else {
|
||||
$perms[] = $req;
|
||||
}
|
||||
|
||||
return $middleware;
|
||||
}
|
||||
if (!empty($perms)) {
|
||||
return implode(',', $perms);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,21 +283,15 @@ class Rsx_Routes_Command extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache TTL from handler
|
||||
* Get cache TTL from route data
|
||||
*
|
||||
* @param array $handler
|
||||
* @param array $route_data
|
||||
* @return int|null
|
||||
*/
|
||||
protected function get_cache_ttl($handler)
|
||||
protected function get_cache_ttl($route_data)
|
||||
{
|
||||
$attributes = $handler['attributes'] ?? [];
|
||||
|
||||
foreach ($attributes as $attr) {
|
||||
if (isset($attr['class']) && str_ends_with($attr['class'], 'Cache')) {
|
||||
return $attr['args']['ttl'] ?? $attr['args'][0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache info would be in route metadata if it exists
|
||||
// Currently not tracked in new structure
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
375
app/RSpade/Commands/Rsx/Task_Process_Command.php
Executable file
375
app/RSpade/Commands/Rsx/Task_Process_Command.php
Executable file
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Commands\Rsx;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Task\Task;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
use App\RSpade\Core\Task\Task_Lock;
|
||||
use App\RSpade\Core\Task\Cron_Parser;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Task Process Command
|
||||
*
|
||||
* Main worker manager for the task system. Handles:
|
||||
* - Processing queued tasks
|
||||
* - Executing scheduled tasks
|
||||
* - Spawning worker processes
|
||||
* - Detecting and recovering stuck tasks
|
||||
*
|
||||
* This command is designed to run via cron every minute:
|
||||
* * * * * * cd /var/www/html && php artisan rsx:task:process
|
||||
*
|
||||
* It will:
|
||||
* 1. Check for stuck tasks and mark them as failed
|
||||
* 2. Process scheduled tasks that are due
|
||||
* 3. Spawn workers for queued tasks (up to concurrency limits)
|
||||
* 4. Exit quickly if no work to do
|
||||
*/
|
||||
class Task_Process_Command extends Command
|
||||
{
|
||||
protected $signature = 'rsx:task:process
|
||||
{--queue= : Process only this specific queue}
|
||||
{--once : Process one task and exit (for testing)}
|
||||
{--force-scheduled : Run all scheduled tasks immediately (ignore next_run_at)}';
|
||||
|
||||
protected $description = 'Process queued and scheduled tasks (run via cron every minute)';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$specific_queue = $this->option('queue');
|
||||
$once_mode = $this->option('once');
|
||||
|
||||
$this->info('[TASK PROCESSOR] Starting task processor');
|
||||
|
||||
// Step 1: Detect and recover stuck tasks
|
||||
$this->detect_stuck_tasks();
|
||||
|
||||
// Step 2: Process scheduled tasks
|
||||
if (!$specific_queue) {
|
||||
$this->process_scheduled_tasks();
|
||||
}
|
||||
|
||||
// Step 3: Spawn workers for queued tasks
|
||||
$this->spawn_workers_for_queues($specific_queue);
|
||||
|
||||
// If in once mode, process one task directly (for testing)
|
||||
if ($once_mode) {
|
||||
$this->process_one_task($specific_queue ?? 'default');
|
||||
}
|
||||
|
||||
$this->info('[TASK PROCESSOR] Task processor complete');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and mark stuck tasks as failed
|
||||
*
|
||||
* A task is considered stuck if:
|
||||
* - Status is 'running'
|
||||
* - Started more than timeout seconds ago
|
||||
* - Worker PID is dead or missing
|
||||
*/
|
||||
private function detect_stuck_tasks(): void
|
||||
{
|
||||
$cleanup_after = config('rsx.tasks.cleanup_stuck_after', 1800);
|
||||
|
||||
$stuck_tasks = DB::table('_task_queue')
|
||||
->where('status', Task_Status::RUNNING)
|
||||
->where('started_at', '<', now()->subSeconds($cleanup_after))
|
||||
->get();
|
||||
|
||||
foreach ($stuck_tasks as $task) {
|
||||
// Check if worker process is still alive
|
||||
$worker_alive = $task->worker_pid && posix_kill($task->worker_pid, 0);
|
||||
|
||||
if (!$worker_alive) {
|
||||
$this->warn("[STUCK TASK] Marking task {$task->id} as failed (worker PID {$task->worker_pid} not responding)");
|
||||
|
||||
DB::table('_task_queue')
|
||||
->where('id', $task->id)
|
||||
->update([
|
||||
'status' => Task_Status::FAILED,
|
||||
'error' => 'Task stuck - worker process not responding',
|
||||
'completed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process scheduled tasks that are due
|
||||
*
|
||||
* Scans manifest for #[Schedule] attributes and ensures each has a database record.
|
||||
* Creates pending task instances for any scheduled tasks that are due.
|
||||
*/
|
||||
private function process_scheduled_tasks(): void
|
||||
{
|
||||
$force_all = $this->option('force-scheduled');
|
||||
|
||||
// Get all scheduled tasks from manifest
|
||||
$scheduled_tasks = Task::get_scheduled_tasks();
|
||||
|
||||
if (empty($scheduled_tasks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("[SCHEDULED] Found " . count($scheduled_tasks) . " scheduled task(s) in manifest");
|
||||
|
||||
foreach ($scheduled_tasks as $task_def) {
|
||||
$this->process_scheduled_task($task_def, $force_all);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single scheduled task definition
|
||||
*
|
||||
* @param array $task_def Task definition from manifest
|
||||
* @param bool $force_run If true, run immediately regardless of schedule
|
||||
*/
|
||||
private function process_scheduled_task(array $task_def, bool $force_run): void
|
||||
{
|
||||
$class = $task_def['class'];
|
||||
$method = $task_def['method'];
|
||||
$cron_expression = $task_def['cron_expression'];
|
||||
$queue = $task_def['queue'];
|
||||
|
||||
// Check if we have a record for this scheduled task
|
||||
$existing = DB::table('_task_queue')
|
||||
->where('class', $class)
|
||||
->where('method', $method)
|
||||
->where('queue', $queue)
|
||||
->whereNotNull('next_run_at')
|
||||
->whereIn('status', [Task_Status::PENDING, Task_Status::RUNNING])
|
||||
->first();
|
||||
|
||||
if (!$existing) {
|
||||
// Create initial scheduled task record
|
||||
$parser = new Cron_Parser($cron_expression);
|
||||
$next_run_at = $parser->get_next_run_time();
|
||||
|
||||
$this->info("[SCHEDULED] Registering new scheduled task: {$class}::{$method}");
|
||||
$this->info("[SCHEDULED] Cron: {$cron_expression}");
|
||||
$this->info("[SCHEDULED] Next run: " . date('Y-m-d H:i:s', $next_run_at));
|
||||
|
||||
DB::table('_task_queue')->insert([
|
||||
'class' => $class,
|
||||
'method' => $method,
|
||||
'queue' => $queue,
|
||||
'status' => Task_Status::PENDING,
|
||||
'params' => json_encode([]),
|
||||
'next_run_at' => date('Y-m-d H:i:s', $next_run_at),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if task is due to run
|
||||
$next_run_timestamp = strtotime($existing->next_run_at);
|
||||
$is_due = $next_run_timestamp <= time();
|
||||
|
||||
if ($force_run || $is_due) {
|
||||
// Task is due - dispatch it
|
||||
$this->info("[SCHEDULED] Dispatching scheduled task: {$class}::{$method}");
|
||||
|
||||
// Create a new pending task instance
|
||||
$task_id = DB::table('_task_queue')->insertGetId([
|
||||
'class' => $class,
|
||||
'method' => $method,
|
||||
'queue' => $queue,
|
||||
'status' => Task_Status::PENDING,
|
||||
'params' => json_encode([]),
|
||||
'scheduled_for' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Calculate next run time and update the scheduled task record
|
||||
$parser = new Cron_Parser($cron_expression);
|
||||
$next_run_at = $parser->get_next_run_time();
|
||||
|
||||
DB::table('_task_queue')
|
||||
->where('id', $existing->id)
|
||||
->update([
|
||||
'next_run_at' => date('Y-m-d H:i:s', $next_run_at),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->info("[SCHEDULED] Created task instance ID: {$task_id}");
|
||||
$this->info("[SCHEDULED] Next run updated to: " . date('Y-m-d H:i:s', $next_run_at));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn workers for queued tasks based on concurrency limits
|
||||
*
|
||||
* @param string|null $specific_queue If set, only process this queue
|
||||
*/
|
||||
private function spawn_workers_for_queues(?string $specific_queue): void
|
||||
{
|
||||
$queue_config = config('rsx.tasks.queues', []);
|
||||
$global_max = config('rsx.tasks.global_max_workers', 1);
|
||||
|
||||
// Count currently running workers across all queues
|
||||
$total_running = DB::table('_task_queue')
|
||||
->where('status', Task_Status::RUNNING)
|
||||
->count();
|
||||
|
||||
if ($total_running >= $global_max) {
|
||||
$this->info("[WORKER SPAWN] Global limit reached ({$total_running}/{$global_max})");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get queues to process
|
||||
$queues_to_process = $specific_queue
|
||||
? [$specific_queue => $queue_config[$specific_queue] ?? ['max_workers' => 1]]
|
||||
: $queue_config;
|
||||
|
||||
foreach ($queues_to_process as $queue_name => $queue_settings) {
|
||||
$max_workers = $queue_settings['max_workers'] ?? 1;
|
||||
|
||||
// Count running workers for this queue
|
||||
$queue_running = DB::table('_task_queue')
|
||||
->where('queue', $queue_name)
|
||||
->where('status', Task_Status::RUNNING)
|
||||
->count();
|
||||
|
||||
// Count pending tasks for this queue (exclude scheduled task records)
|
||||
$queue_pending = DB::table('_task_queue')
|
||||
->where('queue', $queue_name)
|
||||
->where('status', Task_Status::PENDING)
|
||||
->whereNull('next_run_at') // Exclude scheduled task records
|
||||
->where(function ($query) {
|
||||
$query->whereNull('scheduled_for')
|
||||
->orWhere('scheduled_for', '<=', now());
|
||||
})
|
||||
->count();
|
||||
|
||||
if ($queue_pending === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how many workers we can spawn
|
||||
$can_spawn_global = $global_max - $total_running;
|
||||
$can_spawn_queue = $max_workers - $queue_running;
|
||||
$can_spawn = min($can_spawn_global, $can_spawn_queue, $queue_pending);
|
||||
|
||||
if ($can_spawn > 0) {
|
||||
$this->info("[WORKER SPAWN] Spawning {$can_spawn} worker(s) for queue '{$queue_name}'");
|
||||
|
||||
for ($i = 0; $i < $can_spawn; $i++) {
|
||||
$this->spawn_worker($queue_name);
|
||||
$total_running++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a worker process for a queue
|
||||
*
|
||||
* @param string $queue_name Queue to process
|
||||
*/
|
||||
private function spawn_worker(string $queue_name): void
|
||||
{
|
||||
$process = new Process([
|
||||
'php',
|
||||
base_path('artisan'),
|
||||
'rsx:task:worker',
|
||||
'--queue=' . $queue_name,
|
||||
]);
|
||||
|
||||
$process->setTimeout(null);
|
||||
$process->start();
|
||||
|
||||
$this->info("[WORKER SPAWN] Started worker process {$process->getPid()} for queue '{$queue_name}'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one task directly (for testing)
|
||||
*
|
||||
* @param string $queue_name Queue to process from
|
||||
*/
|
||||
private function process_one_task(string $queue_name): void
|
||||
{
|
||||
$this->info("[ONCE MODE] Processing one task from queue '{$queue_name}'");
|
||||
|
||||
// Use lock to ensure atomic task selection
|
||||
$lock = new Task_Lock("task_queue_{$queue_name}");
|
||||
|
||||
if (!$lock->acquire()) {
|
||||
$this->warn("[ONCE MODE] Could not acquire lock for queue '{$queue_name}'");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Select next pending task (exclude scheduled task records)
|
||||
$task_row = DB::table('_task_queue')
|
||||
->where('queue', $queue_name)
|
||||
->where('status', Task_Status::PENDING)
|
||||
->whereNull('next_run_at') // Exclude scheduled task records
|
||||
->where(function ($query) {
|
||||
$query->whereNull('scheduled_for')
|
||||
->orWhere('scheduled_for', '<=', now());
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$task_row) {
|
||||
$this->info("[ONCE MODE] No pending tasks in queue '{$queue_name}'");
|
||||
$lock->release();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as running
|
||||
DB::table('_task_queue')
|
||||
->where('id', $task_row->id)
|
||||
->update([
|
||||
'status' => Task_Status::RUNNING,
|
||||
'started_at' => now(),
|
||||
'worker_pid' => getmypid(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$lock->release();
|
||||
|
||||
// Execute the task
|
||||
$this->info("[ONCE MODE] Executing task {$task_row->id}: {$task_row->class}::{$task_row->method}");
|
||||
|
||||
$task_instance = Task_Instance::find($task_row->id);
|
||||
|
||||
try {
|
||||
$class = $task_row->class;
|
||||
$method = $task_row->method;
|
||||
$params = json_decode($task_row->params, true) ?? [];
|
||||
|
||||
$result = $class::$method($task_instance, $params);
|
||||
|
||||
$task_instance->mark_completed($result);
|
||||
|
||||
$this->info("[ONCE MODE] Task {$task_row->id} completed successfully");
|
||||
} catch (\Exception $e) {
|
||||
$task_instance->mark_failed($e->getMessage());
|
||||
|
||||
$this->error("[ONCE MODE] Task {$task_row->id} failed: " . $e->getMessage());
|
||||
}
|
||||
} finally {
|
||||
if ($lock->is_locked()) {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
app/RSpade/Commands/Rsx/Task_Worker_Command.php
Executable file
189
app/RSpade/Commands/Rsx/Task_Worker_Command.php
Executable file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Commands\Rsx;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
use App\RSpade\Core\Task\Task_Lock;
|
||||
|
||||
/**
|
||||
* Task Worker Command
|
||||
*
|
||||
* Background worker process that continuously processes tasks from a queue.
|
||||
* Spawned by Task_Process_Command, runs until no more tasks or timeout.
|
||||
*
|
||||
* This command:
|
||||
* 1. Acquires lock for queue
|
||||
* 2. Selects next pending task (SELECT FOR UPDATE)
|
||||
* 3. Marks task as running
|
||||
* 4. Executes the task
|
||||
* 5. Marks task as completed/failed
|
||||
* 6. Repeats until no more tasks
|
||||
*
|
||||
* Exit conditions:
|
||||
* - No more pending tasks
|
||||
* - Maximum execution time reached (5 minutes)
|
||||
* - Fatal error
|
||||
*/
|
||||
class Task_Worker_Command extends Command
|
||||
{
|
||||
protected $signature = 'rsx:task:worker
|
||||
{--queue=default : Queue name to process}
|
||||
{--max-time=300 : Maximum execution time in seconds (default: 5 minutes)}';
|
||||
|
||||
protected $description = 'Background worker for processing queued tasks';
|
||||
|
||||
private int $start_time;
|
||||
private int $max_time;
|
||||
private int $tasks_processed = 0;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$queue_name = $this->option('queue');
|
||||
$this->max_time = (int) $this->option('max-time');
|
||||
$this->start_time = time();
|
||||
|
||||
$this->info("[WORKER] Started worker for queue '{$queue_name}' (PID: " . getmypid() . ")");
|
||||
|
||||
// Process tasks until none remain or timeout
|
||||
while (true) {
|
||||
// Check if we've exceeded max execution time
|
||||
if ((time() - $this->start_time) >= $this->max_time) {
|
||||
$this->info("[WORKER] Max execution time reached, exiting");
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to process one task
|
||||
$processed = $this->process_next_task($queue_name);
|
||||
|
||||
if (!$processed) {
|
||||
// No more tasks to process
|
||||
$this->info("[WORKER] No more pending tasks, exiting");
|
||||
break;
|
||||
}
|
||||
|
||||
$this->tasks_processed++;
|
||||
}
|
||||
|
||||
$this->info("[WORKER] Worker finished. Processed {$this->tasks_processed} task(s)");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the next pending task from the queue
|
||||
*
|
||||
* @param string $queue_name Queue to process from
|
||||
* @return bool True if task was processed, false if no tasks available
|
||||
*/
|
||||
private function process_next_task(string $queue_name): bool
|
||||
{
|
||||
// Use lock to ensure atomic task selection
|
||||
$lock = new Task_Lock("task_queue_{$queue_name}", 5);
|
||||
|
||||
if (!$lock->acquire()) {
|
||||
$this->warn("[WORKER] Could not acquire lock for queue '{$queue_name}', retrying...");
|
||||
sleep(1);
|
||||
return true; // Retry
|
||||
}
|
||||
|
||||
try {
|
||||
// Select next pending task (exclude scheduled task records)
|
||||
$task_row = DB::table('_task_queue')
|
||||
->where('queue', $queue_name)
|
||||
->where('status', Task_Status::PENDING)
|
||||
->whereNull('next_run_at') // Exclude scheduled task records
|
||||
->where(function ($query) {
|
||||
$query->whereNull('scheduled_for')
|
||||
->orWhere('scheduled_for', '<=', now());
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$task_row) {
|
||||
// No tasks available
|
||||
$lock->release();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark as running
|
||||
DB::table('_task_queue')
|
||||
->where('id', $task_row->id)
|
||||
->update([
|
||||
'status' => Task_Status::RUNNING,
|
||||
'started_at' => now(),
|
||||
'worker_pid' => getmypid(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$lock->release();
|
||||
|
||||
// Execute the task
|
||||
$this->execute_task($task_row);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("[WORKER] Error processing task: " . $e->getMessage());
|
||||
|
||||
if ($lock->is_locked()) {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task
|
||||
*
|
||||
* @param object $task_row Task database row
|
||||
*/
|
||||
private function execute_task(object $task_row): void
|
||||
{
|
||||
$this->info("[WORKER] Executing task {$task_row->id}: {$task_row->class}::{$task_row->method}");
|
||||
|
||||
$task_instance = Task_Instance::find($task_row->id);
|
||||
|
||||
if (!$task_instance) {
|
||||
$this->error("[WORKER] Could not load task instance for task {$task_row->id}");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$class = $task_row->class;
|
||||
$method = $task_row->method;
|
||||
$params = json_decode($task_row->params, true) ?? [];
|
||||
|
||||
// Check if class exists
|
||||
if (!class_exists($class)) {
|
||||
throw new \Exception("Class not found: {$class}");
|
||||
}
|
||||
|
||||
// Check if method exists
|
||||
if (!method_exists($class, $method)) {
|
||||
throw new \Exception("Method not found: {$class}::{$method}");
|
||||
}
|
||||
|
||||
// Execute the task method
|
||||
$result = $class::$method($task_instance, $params);
|
||||
|
||||
// Mark as completed
|
||||
$task_instance->mark_completed($result);
|
||||
|
||||
$this->info("[WORKER] Task {$task_row->id} completed successfully");
|
||||
} catch (\Exception $e) {
|
||||
// Mark as failed
|
||||
$task_instance->mark_failed($e->getMessage());
|
||||
|
||||
$this->error("[WORKER] Task {$task_row->id} failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
102
app/RSpade/Commands/Thumbnails/Thumbnails_Clean_Command.php
Executable file
102
app/RSpade/Commands/Thumbnails/Thumbnails_Clean_Command.php
Executable file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Commands\Thumbnails;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\RSpade\Core\Files\File_Thumbnail_Service;
|
||||
|
||||
class Thumbnails_Clean_Command extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'rsx:thumbnails:clean
|
||||
{--preset : Clean only preset thumbnails}
|
||||
{--dynamic : Clean only dynamic thumbnails}
|
||||
{--all : Clean both preset and dynamic thumbnails (default)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Enforce thumbnail cache quotas by deleting oldest files until under limit (LRU eviction)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$clean_preset = $this->option('preset') || $this->option('all') || (!$this->option('preset') && !$this->option('dynamic'));
|
||||
$clean_dynamic = $this->option('dynamic') || $this->option('all') || (!$this->option('preset') && !$this->option('dynamic'));
|
||||
|
||||
$total_deleted = 0;
|
||||
$total_freed = 0;
|
||||
|
||||
if ($clean_preset) {
|
||||
[$deleted, $freed] = $this->clean_directory_with_output('preset');
|
||||
$total_deleted += $deleted;
|
||||
$total_freed += $freed;
|
||||
}
|
||||
|
||||
if ($clean_dynamic) {
|
||||
[$deleted, $freed] = $this->clean_directory_with_output('dynamic');
|
||||
$total_deleted += $deleted;
|
||||
$total_freed += $freed;
|
||||
}
|
||||
|
||||
if ($total_deleted > 0) {
|
||||
$freed_mb = round($total_freed / 1024 / 1024, 2);
|
||||
$this->info("Cleanup complete: {$total_deleted} files deleted, {$freed_mb} MB freed");
|
||||
} else {
|
||||
$this->info('No cleanup needed - all directories under quota');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a thumbnail directory with CLI output
|
||||
*
|
||||
* Wrapper around File_Thumbnail_Service::clean_directory() that adds CLI output.
|
||||
*
|
||||
* @param string $type Either 'preset' or 'dynamic'
|
||||
* @return array [files_deleted, bytes_freed]
|
||||
*/
|
||||
protected function clean_directory_with_output($type)
|
||||
{
|
||||
$quota_key = $type . '_max_bytes';
|
||||
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
|
||||
$dir = storage_path("rsx-thumbnails/{$type}/");
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
$this->warn("Directory not found: {$dir}");
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
// Get current stats before cleanup
|
||||
$before_count = count(glob($dir . '*.webp'));
|
||||
$before_size = array_sum(array_map('filesize', glob($dir . '*.webp')));
|
||||
|
||||
$current_mb = round($before_size / 1024 / 1024, 2);
|
||||
$quota_mb = round($max_bytes / 1024 / 1024, 2);
|
||||
|
||||
$this->info("[$type] Current usage: {$current_mb} MB / {$quota_mb} MB ({$before_count} files)");
|
||||
|
||||
// Run cleanup via service
|
||||
[$deleted, $freed] = File_Thumbnail_Service::clean_directory($type);
|
||||
|
||||
if ($deleted > 0) {
|
||||
$over_by = round(($before_size - $max_bytes) / 1024 / 1024, 2);
|
||||
$freed_mb = round($freed / 1024 / 1024, 2);
|
||||
$final_mb = round(($before_size - $freed) / 1024 / 1024, 2);
|
||||
|
||||
$this->warn("[$type] Was over quota by {$over_by} MB");
|
||||
$this->info("[$type] Deleted {$deleted} files, freed {$freed_mb} MB, new usage: {$final_mb} MB");
|
||||
}
|
||||
|
||||
return [$deleted, $freed];
|
||||
}
|
||||
}
|
||||
140
app/RSpade/Commands/Thumbnails/Thumbnails_Generate_Command.php
Executable file
140
app/RSpade/Commands/Thumbnails/Thumbnails_Generate_Command.php
Executable file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Commands\Thumbnails;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\RSpade\Core\Files\File_Attachment_Model;
|
||||
use App\RSpade\Core\Files\File_Attachment_Controller;
|
||||
|
||||
class Thumbnails_Generate_Command extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'rsx:thumbnails:generate
|
||||
{--preset= : Generate specific preset for all attachments (e.g., --preset=profile)}
|
||||
{--key= : Generate thumbnails for specific attachment key}
|
||||
{--all : Generate all presets for all attachments (default if no options specified)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Pre-generate preset thumbnails for attachments (useful for cache warming after deployment)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$preset_filter = $this->option('preset');
|
||||
$key_filter = $this->option('key');
|
||||
$generate_all = $this->option('all') || (!$preset_filter && !$key_filter);
|
||||
|
||||
// Get presets to generate
|
||||
$presets = config('rsx.thumbnails.presets', []);
|
||||
if (empty($presets)) {
|
||||
$this->error('No thumbnail presets defined in config');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$presets_to_generate = $presets;
|
||||
if ($preset_filter) {
|
||||
if (!isset($presets[$preset_filter])) {
|
||||
$this->error("Preset '{$preset_filter}' not defined in config");
|
||||
return 1;
|
||||
}
|
||||
$presets_to_generate = [$preset_filter => $presets[$preset_filter]];
|
||||
}
|
||||
|
||||
// Get attachments to process
|
||||
$query = File_Attachment_Model::query();
|
||||
if ($key_filter) {
|
||||
$query->where('key', $key_filter);
|
||||
}
|
||||
|
||||
$attachments = $query->get();
|
||||
|
||||
if ($attachments->isEmpty()) {
|
||||
$this->warn('No attachments found');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Generating thumbnails for " . count($attachments) . " attachment(s), " . count($presets_to_generate) . " preset(s)");
|
||||
|
||||
$generated_count = 0;
|
||||
$skipped_count = 0;
|
||||
$error_count = 0;
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
foreach ($presets_to_generate as $preset_name => $preset_config) {
|
||||
try {
|
||||
$this->generate_thumbnail($attachment, $preset_name, $preset_config);
|
||||
$generated_count++;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error generating {$preset_name} for {$attachment->key}: " . $e->getMessage());
|
||||
$error_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Complete: {$generated_count} generated, {$skipped_count} skipped (cached), {$error_count} errors");
|
||||
return $error_count > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail for attachment/preset combination
|
||||
*
|
||||
* @param File_Attachment_Model $attachment
|
||||
* @param string $preset_name
|
||||
* @param array $preset_config
|
||||
* @return void
|
||||
*/
|
||||
protected function generate_thumbnail($attachment, $preset_name, $preset_config)
|
||||
{
|
||||
$storage = $attachment->file_storage;
|
||||
if (!$storage || !file_exists($storage->get_full_path())) {
|
||||
throw new \Exception('File not found on disk');
|
||||
}
|
||||
|
||||
// Generate cache filename
|
||||
$cache_filename = File_Attachment_Controller::_get_cache_filename_preset(
|
||||
$preset_name,
|
||||
$storage->hash,
|
||||
$attachment->file_extension
|
||||
);
|
||||
$cache_path = File_Attachment_Controller::_get_cache_path('preset', $cache_filename);
|
||||
|
||||
// Skip if already cached
|
||||
if (file_exists($cache_path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = $preset_config['type'];
|
||||
$width = $preset_config['width'];
|
||||
$height = $preset_config['height'] ?? null;
|
||||
|
||||
// Generate thumbnail
|
||||
if ($attachment->is_image()) {
|
||||
$thumbnail_data = File_Attachment_Controller::__generate_thumbnail(
|
||||
$storage->get_full_path(),
|
||||
$type,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
} else {
|
||||
// Use icon-based thumbnail for non-images
|
||||
$thumbnail_data = \App\RSpade\Core\Files\File_Attachment_Icons::render_icon_as_thumbnail(
|
||||
$attachment->file_extension,
|
||||
$width,
|
||||
$height ?? $width
|
||||
);
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
File_Attachment_Controller::_save_thumbnail_to_cache($cache_path, $thumbnail_data);
|
||||
}
|
||||
}
|
||||
119
app/RSpade/Commands/Thumbnails/Thumbnails_Stats_Command.php
Executable file
119
app/RSpade/Commands/Thumbnails/Thumbnails_Stats_Command.php
Executable file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Commands\Thumbnails;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\RSpade\Core\Files\File_Thumbnail_Service;
|
||||
|
||||
class Thumbnails_Stats_Command extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'rsx:thumbnails:stats';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Display thumbnail cache usage statistics';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stats = File_Thumbnail_Service::get_statistics();
|
||||
|
||||
$this->info('Thumbnail Cache Statistics');
|
||||
$this->info('');
|
||||
|
||||
// Preset thumbnails
|
||||
$this->display_directory_stats('Preset Thumbnails', $stats['preset']);
|
||||
$this->info('');
|
||||
|
||||
// Dynamic thumbnails
|
||||
$this->display_directory_stats('Dynamic Thumbnails', $stats['dynamic']);
|
||||
$this->info('');
|
||||
|
||||
// Breakdown by preset
|
||||
if (!empty($stats['preset_breakdown'])) {
|
||||
$this->display_preset_breakdown($stats['preset_breakdown']);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display statistics for a thumbnail directory
|
||||
*
|
||||
* @param string $title Display title
|
||||
* @param array $stats Statistics from service
|
||||
* @return void
|
||||
*/
|
||||
protected function display_directory_stats($title, $stats)
|
||||
{
|
||||
if (!$stats['exists']) {
|
||||
$this->warn("{$title}: Directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
$total_mb = round($stats['total_bytes'] / 1024 / 1024, 2);
|
||||
$quota_mb = round($stats['max_bytes'] / 1024 / 1024, 2);
|
||||
|
||||
$this->info("{$title}:");
|
||||
$this->info(" Files: " . number_format($stats['file_count']));
|
||||
$this->info(" Total Size: {$total_mb} MB / {$quota_mb} MB ({$stats['usage_percent']}%)");
|
||||
|
||||
if ($stats['oldest'] !== null) {
|
||||
$oldest_age = $this->format_age($stats['oldest']);
|
||||
$newest_age = $this->format_age($stats['newest']);
|
||||
$this->info(" Oldest: {$oldest_age}");
|
||||
$this->info(" Newest: {$newest_age}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display breakdown of preset thumbnails by preset name
|
||||
*
|
||||
* @param array $breakdown Breakdown from service
|
||||
* @return void
|
||||
*/
|
||||
protected function display_preset_breakdown($breakdown)
|
||||
{
|
||||
$this->info('Breakdown by preset:');
|
||||
foreach ($breakdown as $preset_name => $stats) {
|
||||
if ($stats['file_count'] > 0) {
|
||||
$mb = round($stats['total_bytes'] / 1024 / 1024, 2);
|
||||
$this->info(" {$preset_name}: {$stats['file_count']} files, {$mb} MB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp as human-readable age
|
||||
*
|
||||
* @param int $timestamp Unix timestamp
|
||||
* @return string Human-readable age
|
||||
*/
|
||||
protected function format_age($timestamp)
|
||||
{
|
||||
$age = time() - $timestamp;
|
||||
|
||||
if ($age < 60) {
|
||||
return "{$age} seconds ago";
|
||||
} elseif ($age < 3600) {
|
||||
$minutes = floor($age / 60);
|
||||
return "{$minutes} minute" . ($minutes > 1 ? 's' : '') . " ago";
|
||||
} elseif ($age < 86400) {
|
||||
$hours = floor($age / 3600);
|
||||
return "{$hours} hour" . ($hours > 1 ? 's' : '') . " ago";
|
||||
} else {
|
||||
$days = floor($age / 86400);
|
||||
return "{$days} day" . ($days > 1 ? 's' : '') . " ago";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,12 @@ class Ajax
|
||||
$json_response['console_debug'] = $console_messages;
|
||||
}
|
||||
|
||||
// Add flash_alerts if any pending for current session
|
||||
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
||||
if (!empty($flash_messages)) {
|
||||
$json_response['flash_alerts'] = $flash_messages;
|
||||
}
|
||||
|
||||
return response()->json($json_response);
|
||||
}
|
||||
|
||||
@@ -436,6 +442,12 @@ class Ajax
|
||||
$json_response['console_debug'] = $console_messages;
|
||||
}
|
||||
|
||||
// Add flash_alerts if any pending for current session
|
||||
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
||||
if (!empty($flash_messages)) {
|
||||
$json_response['flash_alerts'] = $flash_messages;
|
||||
}
|
||||
|
||||
return $json_response;
|
||||
}
|
||||
|
||||
|
||||
@@ -2031,7 +2031,18 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
// Get file data from manifest
|
||||
$file_data = $manifest_files[$relative] ?? null;
|
||||
|
||||
// If we have class information from manifest, use it
|
||||
// All JavaScript files MUST be in manifest
|
||||
if ($file_data === null) {
|
||||
throw new \RuntimeException(
|
||||
"JavaScript file in bundle but not in manifest (this should never happen):\n" .
|
||||
"File: {$file}\n" .
|
||||
"Relative: {$relative}\n" .
|
||||
"Bundle: {$this->bundle_name}\n" .
|
||||
"Manifest has " . count($manifest_files) . " files total"
|
||||
);
|
||||
}
|
||||
|
||||
// If file has a class, add to class definitions
|
||||
if (!empty($file_data['class'])) {
|
||||
$class_name = $file_data['class'];
|
||||
$extends_class = $file_data['extends'] ?? null;
|
||||
@@ -2046,19 +2057,8 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
'extends' => $extends_class,
|
||||
'decorators' => $file_data['method_decorators'] ?? null,
|
||||
];
|
||||
} elseif (file_exists($file)) {
|
||||
// Parse the file directly for classes
|
||||
$content = file_get_contents($file);
|
||||
$classes = $this->_parse_javascript_classes($content);
|
||||
|
||||
foreach ($classes as $class_info) {
|
||||
$class_definitions[$class_info['name']] = [
|
||||
'name' => $class_info['name'],
|
||||
'extends' => $class_info['extends'],
|
||||
'decorators' => null, // No decorator info when parsing directly
|
||||
];
|
||||
}
|
||||
}
|
||||
// Otherwise, it's a standalone function file - no manifest registration needed
|
||||
}
|
||||
|
||||
// If no classes found, return null
|
||||
@@ -2105,27 +2105,6 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
return $this->_write_temp_file($js_code, 'js');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JavaScript content to extract class definitions
|
||||
*/
|
||||
protected function _parse_javascript_classes(string $content): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
// Match ES6 class declarations with optional extends
|
||||
$pattern = '/(?:^|\s)class\s+([A-Z]\w*)(?:\s+extends\s+([A-Z]\w*))?/m';
|
||||
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$classes[] = [
|
||||
'name' => $match[1],
|
||||
'extends' => isset($match[2]) ? $match[2] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JavaScript runner for automatic class initialization
|
||||
|
||||
@@ -24,6 +24,8 @@ class Core_Bundle extends Rsx_Bundle_Abstract
|
||||
__DIR__,
|
||||
'app/RSpade/Core/Js',
|
||||
'app/RSpade/Core/Data',
|
||||
'app/RSpade/Core/SPA',
|
||||
'app/RSpade/Lib',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -255,6 +255,14 @@ abstract class Rsx_Bundle_Abstract
|
||||
// Add build_key (always included in both dev and production)
|
||||
$rsxapp_data['build_key'] = $manifest_hash;
|
||||
|
||||
// Add session_hash if session exists (hashed for scoping, non-reversible)
|
||||
$session_token = $_COOKIE['rsx'] ?? null;
|
||||
if ($session_token) {
|
||||
$rsxapp_data['session_hash'] = hash_hmac('sha256', $session_token, config('app.key'));
|
||||
} else {
|
||||
$rsxapp_data['session_hash'] = null;
|
||||
}
|
||||
|
||||
// Add bundle data if present
|
||||
if (!empty($compiled['config'])) {
|
||||
$rsxapp_data = array_merge($rsxapp_data, $compiled['bundle_data'] ?? []);
|
||||
@@ -265,6 +273,7 @@ abstract class Rsx_Bundle_Abstract
|
||||
$rsxapp_data['current_controller'] = \App\RSpade\Core\Rsx::get_current_controller();
|
||||
$rsxapp_data['current_action'] = \App\RSpade\Core\Rsx::get_current_action();
|
||||
$rsxapp_data['is_auth'] = Session::is_logged_in();
|
||||
$rsxapp_data['is_spa'] = \App\RSpade\Core\Rsx::is_spa();
|
||||
$rsxapp_data['ajax_disable_batching'] = config('rsx.development.ajax_disable_batching', false);
|
||||
|
||||
// Add current params (always set to reduce state variations)
|
||||
@@ -286,6 +295,12 @@ abstract class Rsx_Bundle_Abstract
|
||||
$rsxapp_data['page_data'] = \App\RSpade\Core\View\PageData::get();
|
||||
}
|
||||
|
||||
// Add flash_alerts if any pending for current session
|
||||
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
||||
if (!empty($flash_messages)) {
|
||||
$rsxapp_data['flash_alerts'] = $flash_messages;
|
||||
}
|
||||
|
||||
// Add console_debug config in non-production mode
|
||||
if (!app()->environment('production')) {
|
||||
$console_debug_config = config('rsx.console_debug', []);
|
||||
@@ -641,8 +656,8 @@ abstract class Rsx_Bundle_Abstract
|
||||
}
|
||||
}
|
||||
|
||||
// Throw error if not covered
|
||||
if (!$is_covered) {
|
||||
// Throw error if not covered (skip for framework views in app/RSpade/)
|
||||
if (!$is_covered && !str_starts_with($view_path, 'app/RSpade/')) {
|
||||
// Bundle doesn't include the view directory
|
||||
BundleErrors::view_not_covered($view_path, $view_dir, $bundle_class, $include_paths);
|
||||
}
|
||||
|
||||
@@ -200,8 +200,12 @@ class SqlQueryTransformer
|
||||
*/
|
||||
private static function __validate_forbidden_types(string $query): void
|
||||
{
|
||||
// Strip comments and quoted strings before validation to avoid false positives
|
||||
// (e.g., "TIME" in a COMMENT won't trigger the TIME type check)
|
||||
$query_without_strings = self::__strip_comments_and_strings($query);
|
||||
|
||||
// Check for ENUM
|
||||
if (preg_match('/\bENUM\s*\(/i', $query)) {
|
||||
if (preg_match('/\bENUM\s*\(/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"ENUM column type is forbidden in RSpade. Use VARCHAR with validation instead.\n" .
|
||||
"ENUMs cannot be modified without table locks and cause deployment issues.\n" .
|
||||
@@ -210,7 +214,7 @@ class SqlQueryTransformer
|
||||
}
|
||||
|
||||
// Check for SET
|
||||
if (preg_match('/\bSET\s*\(/i', $query)) {
|
||||
if (preg_match('/\bSET\s*\(/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"SET column type is forbidden in RSpade. Use JSON or a separate table instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
@@ -218,7 +222,7 @@ class SqlQueryTransformer
|
||||
}
|
||||
|
||||
// Check for YEAR
|
||||
if (preg_match('/\bYEAR\b/i', $query)) {
|
||||
if (preg_match('/\bYEAR\b/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"YEAR column type is forbidden in RSpade. Use INT or DATE instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
@@ -226,14 +230,102 @@ class SqlQueryTransformer
|
||||
}
|
||||
|
||||
// Check for TIME (but allow DATETIME and TIMESTAMP)
|
||||
if (preg_match('/\bTIME\b(?!STAMP)/i', $query)) {
|
||||
throw new \RuntimeException(
|
||||
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
);
|
||||
if (preg_match('/\bTIME\b(?!STAMP)/i', $query_without_strings)) {
|
||||
// Additional check: make sure it's not part of DATETIME
|
||||
if (!preg_match('/\bDATETIME\b/i', $query_without_strings) ||
|
||||
preg_match('/[,\s]\s*TIME\s+/i', $query_without_strings)) {
|
||||
throw new \RuntimeException(
|
||||
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
|
||||
"Query: " . substr($query, 0, 200)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip comments and quoted strings from SQL query
|
||||
*
|
||||
* Removes:
|
||||
* - Single-quoted strings
|
||||
* - Double-quoted strings
|
||||
* - SQL comments (both single-line and multi-line)
|
||||
*
|
||||
* This prevents false positives in validation (e.g., 'TIME' in a comment)
|
||||
*
|
||||
* @param string $query The SQL query
|
||||
* @return string Query with comments and strings removed
|
||||
*/
|
||||
private static function __strip_comments_and_strings(string $query): string
|
||||
{
|
||||
$result = '';
|
||||
$len = strlen($query);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $len) {
|
||||
// Check for single-line comment --
|
||||
if ($i < $len - 1 && $query[$i] === '-' && $query[$i + 1] === '-') {
|
||||
// Skip until newline
|
||||
while ($i < $len && $query[$i] !== "\n") {
|
||||
$i++;
|
||||
}
|
||||
$i++; // Skip the newline
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for multi-line comment /* */
|
||||
if ($i < $len - 1 && $query[$i] === '/' && $query[$i + 1] === '*') {
|
||||
// Skip until */
|
||||
$i += 2;
|
||||
while ($i < $len - 1 && !($query[$i] === '*' && $query[$i + 1] === '/')) {
|
||||
$i++;
|
||||
}
|
||||
$i += 2; // Skip the */
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for single-quoted string
|
||||
if ($query[$i] === "'") {
|
||||
$i++; // Skip opening quote
|
||||
while ($i < $len) {
|
||||
if ($query[$i] === "'") {
|
||||
// Check for escaped quote ''
|
||||
if ($i + 1 < $len && $query[$i + 1] === "'") {
|
||||
$i += 2; // Skip escaped quote
|
||||
} else {
|
||||
$i++; // Skip closing quote
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for double-quoted string
|
||||
if ($query[$i] === '"') {
|
||||
$i++; // Skip opening quote
|
||||
while ($i < $len) {
|
||||
if ($query[$i] === '"') {
|
||||
$i++; // Skip closing quote
|
||||
break;
|
||||
} else if ($query[$i] === '\\' && $i + 1 < $len) {
|
||||
$i += 2; // Skip escaped character
|
||||
} else {
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular character - keep it
|
||||
$result .= $query[$i];
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate MySQL syntax patterns that commonly cause issues
|
||||
*
|
||||
|
||||
@@ -122,7 +122,7 @@ class Dispatcher
|
||||
|
||||
console_debug('DISPATCH', 'Matched default route pattern:', $controller_name, '::', $action_name);
|
||||
|
||||
// Try to find the controller using manifest
|
||||
// First try to find as PHP controller
|
||||
try {
|
||||
$metadata = Manifest::php_get_metadata_by_class($controller_name);
|
||||
$controller_fqcn = $metadata['fqcn'];
|
||||
@@ -173,7 +173,56 @@ class Dispatcher
|
||||
return redirect($proper_url, 302);
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
console_debug('DISPATCH', 'Controller not found in manifest:', $controller_name);
|
||||
console_debug('DISPATCH', 'Not a PHP controller, checking if SPA action:', $controller_name);
|
||||
|
||||
// Not found as PHP controller - check if it's a SPA action
|
||||
try {
|
||||
$is_spa_action = Manifest::js_is_subclass_of($controller_name, 'Spa_Action');
|
||||
|
||||
if ($is_spa_action) {
|
||||
console_debug('DISPATCH', 'Found SPA action class:', $controller_name);
|
||||
|
||||
// Get the file path for this JS class
|
||||
$file_path = Manifest::js_find_class($controller_name);
|
||||
|
||||
// Get file metadata which contains decorator information
|
||||
$file_data = Manifest::get_file($file_path);
|
||||
|
||||
if (!$file_data) {
|
||||
console_debug('DISPATCH', 'SPA action metadata not found:', $controller_name);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract route pattern from @route() decorator
|
||||
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
|
||||
$route_pattern = null;
|
||||
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
|
||||
foreach ($file_data['decorators'] as $decorator) {
|
||||
if (isset($decorator[0]) && $decorator[0] === 'route') {
|
||||
if (isset($decorator[1][0])) {
|
||||
$route_pattern = $decorator[1][0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($route_pattern) {
|
||||
// Generate proper URL for the SPA action
|
||||
$params = array_merge($extra_params, $request->query->all());
|
||||
$proper_url = Rsx::Route($controller_name, $action_name, $params);
|
||||
|
||||
console_debug('DISPATCH', 'Redirecting to SPA action route:', $proper_url);
|
||||
|
||||
return redirect($proper_url, 302);
|
||||
} else {
|
||||
console_debug('DISPATCH', 'SPA action missing @route() decorator:', $controller_name);
|
||||
}
|
||||
}
|
||||
} catch (\RuntimeException $spa_e) {
|
||||
console_debug('DISPATCH', 'Not a SPA action either:', $controller_name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -246,7 +295,8 @@ class Dispatcher
|
||||
Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params));
|
||||
|
||||
// Set current controller and action in Rsx for tracking
|
||||
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params);
|
||||
$route_type = $route_match['type'] ?? 'standard';
|
||||
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type);
|
||||
|
||||
// Load and validate handler class
|
||||
static::__load_handler_class($handler_class);
|
||||
@@ -339,93 +389,78 @@ class Dispatcher
|
||||
if (empty($routes)) {
|
||||
\Log::debug('Manifest::get_routes() returned empty array');
|
||||
console_debug('DISPATCH', 'Warning: got 0 routes from Manifest::get_routes()');
|
||||
} else {
|
||||
\Log::debug('Manifest has ' . count($routes) . ' route types');
|
||||
// Log details for debugging but don't output to console
|
||||
foreach ($routes as $type => $type_routes) {
|
||||
\Log::debug("Route type '$type' has " . count($type_routes) . ' patterns');
|
||||
// Show first few patterns for debugging in logs only
|
||||
$patterns = array_slice(array_keys($type_routes), 0, 5);
|
||||
\Log::debug(' First patterns: ' . implode(', ', $patterns));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort handler types by priority
|
||||
$sorted_types = array_keys(static::$handler_priorities);
|
||||
usort($sorted_types, function ($a, $b) {
|
||||
return static::$handler_priorities[$a] - static::$handler_priorities[$b];
|
||||
});
|
||||
\Log::debug('Manifest has ' . count($routes) . ' routes');
|
||||
|
||||
// Collect all matching routes
|
||||
$matches = [];
|
||||
// Get all patterns and sort by priority
|
||||
$patterns = array_keys($routes);
|
||||
$patterns = RouteResolver::sort_by_priority($patterns);
|
||||
|
||||
// Try each handler type in priority order
|
||||
foreach ($sorted_types as $type) {
|
||||
if (!isset($routes[$type])) {
|
||||
// Try to match each pattern
|
||||
foreach ($patterns as $pattern) {
|
||||
$route = $routes[$pattern];
|
||||
|
||||
// Check if HTTP method is supported
|
||||
if (!in_array($method, $route['methods'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type_routes = $routes[$type];
|
||||
// Try to match the URL
|
||||
$params = RouteResolver::match_with_query($url, $pattern);
|
||||
|
||||
// Get all patterns for this type
|
||||
$patterns = array_keys($type_routes);
|
||||
if ($params !== false) {
|
||||
// Found a match - verify the method has the required attribute
|
||||
$class_fqcn = $route['class'];
|
||||
$method_name = $route['method'];
|
||||
|
||||
// Sort patterns by priority
|
||||
$patterns = RouteResolver::sort_by_priority($patterns);
|
||||
// Get method metadata from manifest
|
||||
$class_metadata = Manifest::php_get_metadata_by_fqcn($class_fqcn);
|
||||
$method_metadata = $class_metadata['public_static_methods'][$method_name] ?? null;
|
||||
|
||||
// Try to match each pattern
|
||||
foreach ($patterns as $pattern) {
|
||||
$route_info = $type_routes[$pattern];
|
||||
if (!$method_metadata) {
|
||||
throw new \RuntimeException(
|
||||
"Route method not found in manifest: {$class_fqcn}::{$method_name}\n" .
|
||||
"Pattern: {$pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if method is supported
|
||||
if (isset($route_info[$method])) {
|
||||
// Try to match the URL
|
||||
$params = RouteResolver::match_with_query($url, $pattern);
|
||||
// Check for Route or SPA attribute
|
||||
$attributes = $method_metadata['attributes'] ?? [];
|
||||
$has_route = false;
|
||||
|
||||
if ($params !== false) {
|
||||
// Handle new structure where each method can have multiple handlers
|
||||
$handlers = $route_info[$method];
|
||||
|
||||
// If it's not an array of handlers, convert it (backwards compatibility)
|
||||
if (!isset($handlers[0])) {
|
||||
$handlers = [$handlers];
|
||||
}
|
||||
|
||||
// Add all matching handlers
|
||||
foreach ($handlers as $handler) {
|
||||
$matches[] = [
|
||||
'type' => $type,
|
||||
'pattern' => $pattern,
|
||||
'class' => $handler['class'],
|
||||
'method' => $handler['method'],
|
||||
'params' => $params,
|
||||
'file' => $handler['file'] ?? null,
|
||||
'require' => $handler['require'] ?? [],
|
||||
];
|
||||
}
|
||||
foreach ($attributes as $attr_name => $attr_instances) {
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route' ||
|
||||
str_ends_with($attr_name, '\\SPA') || $attr_name === 'SPA') {
|
||||
$has_route = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate routes
|
||||
if (count($matches) > 1) {
|
||||
$error_msg = "Multiple routes match the request '{$method} {$url}':\n\n";
|
||||
foreach ($matches as $match) {
|
||||
$error_msg .= " - Pattern: {$match['pattern']}\n";
|
||||
$error_msg .= " Class: {$match['class']}::{$match['method']}\n";
|
||||
if (!empty($match['file'])) {
|
||||
$error_msg .= " File: {$match['file']}\n";
|
||||
if (!$has_route) {
|
||||
throw new \RuntimeException(
|
||||
"Route method {$class_fqcn}::{$method_name} is missing required #[Route] or #[SPA] attribute.\n" .
|
||||
"Pattern: {$pattern}\n" .
|
||||
"File: {$route['file']}"
|
||||
);
|
||||
}
|
||||
$error_msg .= " Type: {$match['type']}\n\n";
|
||||
}
|
||||
$error_msg .= 'Routes must be unique. Please remove duplicate route definitions.';
|
||||
|
||||
throw new RuntimeException($error_msg);
|
||||
// Return route with params
|
||||
return [
|
||||
'type' => $route['type'],
|
||||
'pattern' => $pattern,
|
||||
'class' => $route['class'],
|
||||
'method' => $route['method'],
|
||||
'params' => $params,
|
||||
'file' => $route['file'] ?? null,
|
||||
'require' => $route['require'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Return the single match or null
|
||||
return $matches[0] ?? null;
|
||||
// No match found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,8 +607,9 @@ class Dispatcher
|
||||
throw new Exception("Method not public: {$class_name}::{$method_name}");
|
||||
}
|
||||
|
||||
// Set current controller and action for tracking
|
||||
Rsx::_set_current_controller_action($class_name, $method_name, $params);
|
||||
// NOTE: Do NOT call _set_current_controller_action here - it's already been set
|
||||
// earlier in the dispatch flow with the correct route type. Calling it again
|
||||
// would overwrite the route type with null.
|
||||
|
||||
// Check if this is a controller (all methods are static)
|
||||
if (static::__is_controller($class_name)) {
|
||||
@@ -1096,7 +1132,11 @@ class Dispatcher
|
||||
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
|
||||
);
|
||||
}
|
||||
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index');
|
||||
$action = $redirect_to[0];
|
||||
if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') {
|
||||
$action .= '::' . $redirect_to[1];
|
||||
}
|
||||
$url = Rsx::Route($action);
|
||||
if ($message) {
|
||||
Rsx::flash_error($message);
|
||||
}
|
||||
|
||||
120
app/RSpade/Core/Dispatch/Route_ManifestSupport.php
Executable file
120
app/RSpade/Core/Dispatch/Route_ManifestSupport.php
Executable file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Dispatch;
|
||||
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for building routes index from #[Route] attributes
|
||||
* This runs after the primary manifest is built to create routes index
|
||||
*/
|
||||
class Route_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Routes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the manifest and build routes index
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize routes key
|
||||
if (!isset($manifest_data['data']['routes'])) {
|
||||
$manifest_data['data']['routes'] = [];
|
||||
}
|
||||
|
||||
// Look for Route attributes - must check all namespaces since Route is not a real class
|
||||
// PHP attributes without an import will use the current namespace
|
||||
$files = $manifest_data['data']['files'];
|
||||
$route_classes = [];
|
||||
|
||||
foreach ($files as $file => $metadata) {
|
||||
// Check public static method attributes for any attribute ending with 'Route'
|
||||
if (isset($metadata['public_static_methods'])) {
|
||||
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
|
||||
if (isset($method_data['attributes'])) {
|
||||
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
|
||||
// Check if this is a Route attribute (ends with \Route or is just Route)
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') {
|
||||
$route_classes[] = [
|
||||
'file' => $file,
|
||||
'class' => $metadata['class'] ?? null,
|
||||
'fqcn' => $metadata['fqcn'] ?? null,
|
||||
'method' => $method_name,
|
||||
'type' => 'method',
|
||||
'instances' => $attr_instances,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($route_classes as $item) {
|
||||
if ($item['type'] === 'method') {
|
||||
foreach ($item['instances'] as $route_args) {
|
||||
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
|
||||
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
|
||||
$name = $route_args[2] ?? ($route_args['name'] ?? null);
|
||||
|
||||
if ($pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($pattern[0] !== '/') {
|
||||
$pattern = '/' . $pattern;
|
||||
}
|
||||
|
||||
// Type is always 'standard' for routes with #[Route] attribute
|
||||
$type = 'standard';
|
||||
|
||||
// Extract Auth attributes for this method from the file metadata
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$item['file']] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'];
|
||||
}
|
||||
|
||||
// Check for duplicate route definition (pattern must be unique across all route types)
|
||||
if (isset($manifest_data['data']['routes'][$pattern])) {
|
||||
$existing = $manifest_data['data']['routes'][$pattern];
|
||||
$existing_type = $existing['type'];
|
||||
$existing_location = $existing_type === 'spa'
|
||||
? "SPA action {$existing['js_action_class']} in {$existing['file']}"
|
||||
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Duplicate route definition: {$pattern}\n" .
|
||||
" Already defined: {$existing_location}\n" .
|
||||
" Conflicting: {$item['fqcn']}::{$item['method']} in {$item['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Store route with flat structure
|
||||
$manifest_data['data']['routes'][$pattern] = [
|
||||
'methods' => array_map('strtoupper', (array) $methods),
|
||||
'type' => $type,
|
||||
'class' => $item['fqcn'] ?? $item['class'],
|
||||
'method' => $item['method'],
|
||||
'name' => $name,
|
||||
'file' => $item['file'],
|
||||
'require' => $require_attrs,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
|
||||
ksort($manifest_data['data']['routes']);
|
||||
}
|
||||
}
|
||||
229
app/RSpade/Core/Files/CLAUDE.md
Executable file
229
app/RSpade/Core/Files/CLAUDE.md
Executable file
@@ -0,0 +1,229 @@
|
||||
# File Attachments System
|
||||
|
||||
## Overview
|
||||
|
||||
The RSpade file attachment system provides secure, session-based file uploads with automatic thumbnail generation and polymorphic model associations.
|
||||
|
||||
## Upload Flow
|
||||
|
||||
**Security Model**: Files upload UNATTACHED → validate → assign via API
|
||||
|
||||
1. User uploads file to `/_upload` endpoint
|
||||
2. File saved with `session_id`, no model association
|
||||
3. Returns unique `key` to frontend
|
||||
4. Frontend calls API endpoint with key
|
||||
5. Backend validates ownership and assigns to model
|
||||
|
||||
## Security Implementation
|
||||
|
||||
**Session-based validation** prevents cross-user file assignment:
|
||||
- Files get `session_id` on upload
|
||||
- `can_user_assign_this_file()` validates:
|
||||
- File not already assigned
|
||||
- Same site_id as user's session
|
||||
- Same session_id (prevents cross-user assignment)
|
||||
- User-provided `fileable_*` params ignored during upload
|
||||
|
||||
## Attachment API
|
||||
|
||||
### File_Attachment_Model Methods
|
||||
|
||||
```php
|
||||
// Find attachment by key
|
||||
$attachment = File_Attachment_Model::find_by_key($key);
|
||||
|
||||
// Validate user can assign
|
||||
if ($attachment->can_user_assign_this_file()) {
|
||||
// Single file attachment (replaces existing)
|
||||
$attachment->attach_to($user, 'profile_photo');
|
||||
|
||||
// Multiple file attachment (adds to collection)
|
||||
$attachment->add_to($project, 'documents');
|
||||
}
|
||||
|
||||
// Remove assignment
|
||||
$attachment->detach();
|
||||
|
||||
// Check assignment status
|
||||
if ($attachment->is_attached()) {
|
||||
// File is assigned to a model
|
||||
}
|
||||
```
|
||||
|
||||
### Model Helper Methods
|
||||
|
||||
All models extending `Rsx_Model_Abstract` have attachment helpers:
|
||||
|
||||
```php
|
||||
// Get single attachment
|
||||
$photo = $user->get_attachment('profile_photo');
|
||||
|
||||
// Get multiple attachments
|
||||
$docs = $project->get_attachments('documents');
|
||||
|
||||
// Count attachments
|
||||
$count = $project->count_attachments('documents');
|
||||
|
||||
// Check if has attachments
|
||||
if ($user->has_attachment('profile_photo')) {
|
||||
// User has profile photo
|
||||
}
|
||||
```
|
||||
|
||||
## Display URLs
|
||||
|
||||
```php
|
||||
// Thumbnail with specific dimensions
|
||||
$photo->get_thumbnail_url('cover', 128, 128);
|
||||
|
||||
// Full file URL
|
||||
$photo->get_url();
|
||||
|
||||
// Force download URL
|
||||
$photo->get_download_url();
|
||||
|
||||
// Get file metadata
|
||||
$size = $photo->file_size;
|
||||
$mime = $photo->mime_type;
|
||||
$name = $photo->original_filename;
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `/_upload` - File upload endpoint
|
||||
- `/_download/:key` - Force download file
|
||||
- `/_thumbnail/:key/:type/:w/:h` - Generated thumbnail
|
||||
- `/_file/:key` - Direct file access
|
||||
|
||||
## Thumbnail System
|
||||
|
||||
Thumbnails are generated on-demand and cached:
|
||||
|
||||
**Preset types** (defined in config):
|
||||
- `cover` - Cover image aspect ratio
|
||||
- `square` - 1:1 aspect ratio
|
||||
- `landscape` - 16:9 aspect ratio
|
||||
|
||||
**Dynamic thumbnails**:
|
||||
- Limited to `max_dynamic_size` (default 2000px)
|
||||
- Cached for performance
|
||||
- Automatic cleanup via scheduled task
|
||||
|
||||
## Controller Implementation Pattern
|
||||
|
||||
```php
|
||||
#[Ajax_Endpoint]
|
||||
public static function save_with_photo(Request $request, array $params = [])
|
||||
{
|
||||
// Validate required fields
|
||||
if (empty($params['name'])) {
|
||||
return response_form_error('Validation failed', [
|
||||
'name' => 'Name is required'
|
||||
]);
|
||||
}
|
||||
|
||||
// Save model
|
||||
$user = new User_Model();
|
||||
$user->name = $params['name'];
|
||||
$user->save();
|
||||
|
||||
// Attach photo if provided
|
||||
if (!empty($params['photo_key'])) {
|
||||
$photo = File_Attachment_Model::find_by_key($params['photo_key']);
|
||||
|
||||
if (!$photo || !$photo->can_user_assign_this_file()) {
|
||||
return response_form_error('Invalid file', [
|
||||
'photo' => 'File not found or access denied'
|
||||
]);
|
||||
}
|
||||
|
||||
$photo->attach_to($user, 'profile_photo');
|
||||
}
|
||||
|
||||
return ['user_id' => $user->id];
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Upload Component
|
||||
|
||||
```javascript
|
||||
// Using Rsx_File_Upload component
|
||||
<Rsx_File_Upload
|
||||
$id="photo_upload"
|
||||
$accept="image/*"
|
||||
$max_size="5242880"
|
||||
/>
|
||||
|
||||
// Get uploaded file key
|
||||
const key = this.$id('photo_upload').component().get_file_key();
|
||||
|
||||
// Submit with form
|
||||
const data = {
|
||||
name: this.$id('name').val(),
|
||||
photo_key: key
|
||||
};
|
||||
await Controller.save_with_photo(data);
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
file_attachments
|
||||
├── id (bigint)
|
||||
├── key (varchar 64, unique)
|
||||
├── site_id (bigint)
|
||||
├── session_id (bigint)
|
||||
├── fileable_type (varchar 255, nullable)
|
||||
├── fileable_id (bigint, nullable)
|
||||
├── fileable_key (varchar 255, nullable)
|
||||
├── storage_path (varchar 500)
|
||||
├── original_filename (varchar 500)
|
||||
├── mime_type (varchar 255)
|
||||
├── file_size (bigint)
|
||||
├── metadata (json)
|
||||
└── timestamps
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
In `/system/config/rsx.php`:
|
||||
|
||||
```php
|
||||
'attachments' => [
|
||||
'upload_dir' => storage_path('rsx-attachments'),
|
||||
'max_upload_size' => 10 * 1024 * 1024, // 10MB
|
||||
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx'],
|
||||
],
|
||||
|
||||
'thumbnails' => [
|
||||
'presets' => [
|
||||
'cover' => ['width' => 800, 'height' => 600],
|
||||
'square' => ['width' => 300, 'height' => 300],
|
||||
],
|
||||
'max_dynamic_size' => 2000,
|
||||
'quotas' => [
|
||||
'preset_max_bytes' => 500 * 1024 * 1024, // 500MB
|
||||
'dynamic_max_bytes' => 100 * 1024 * 1024, // 100MB
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Never trust client-provided fileable_* params** during upload
|
||||
2. **Always validate ownership** before assignment
|
||||
3. **Use polymorphic associations** for flexibility
|
||||
4. **Implement access control** in download endpoints
|
||||
5. **Sanitize filenames** to prevent directory traversal
|
||||
6. **Validate MIME types** server-side
|
||||
7. **Set appropriate upload size limits**
|
||||
8. **Use scheduled cleanup** for orphaned files
|
||||
|
||||
## Scheduled Cleanup
|
||||
|
||||
Orphaned files (uploaded but never assigned) are cleaned automatically:
|
||||
- Files older than 24 hours without assignment
|
||||
- Runs daily via scheduled task
|
||||
- Preserves actively used files
|
||||
|
||||
See also: `php artisan rsx:man file_upload`
|
||||
@@ -418,7 +418,146 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
// ============================================================================================
|
||||
|
||||
/**
|
||||
* Generate thumbnail for image attachments
|
||||
* Generate and serve thumbnail with caching
|
||||
*
|
||||
* Common logic for both preset and dynamic thumbnails.
|
||||
*
|
||||
* @param File_Attachment_Model $attachment
|
||||
* @param string $type Thumbnail type: 'cover' or 'fit'
|
||||
* @param int $width Width in pixels
|
||||
* @param int|null $height Height in pixels
|
||||
* @param string $cache_type Either 'preset' or 'dynamic'
|
||||
* @param string $cache_filename Cache filename
|
||||
* @return Response
|
||||
*/
|
||||
protected static function __generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
$cache_type,
|
||||
$cache_filename
|
||||
) {
|
||||
// Get storage file
|
||||
$storage = $attachment->file_storage;
|
||||
if (!$storage || !file_exists($storage->get_full_path())) {
|
||||
abort(404, 'File not found on disk');
|
||||
}
|
||||
|
||||
// Build cache path
|
||||
$cache_path = static::_get_cache_path($cache_type, $cache_filename);
|
||||
|
||||
// Check cache
|
||||
if (file_exists($cache_path)) {
|
||||
$response = static::_serve_cached_thumbnail($cache_path);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
// If null, file was deleted (race condition) - fall through to regeneration
|
||||
}
|
||||
|
||||
// Generate thumbnail
|
||||
if ($attachment->is_image()) {
|
||||
$thumbnail_data = static::__generate_thumbnail(
|
||||
$storage->get_full_path(),
|
||||
$type,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
} else {
|
||||
// Use icon-based thumbnail for non-images
|
||||
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
|
||||
$attachment->file_extension,
|
||||
$width,
|
||||
$height ?? $width
|
||||
);
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
static::_save_thumbnail_to_cache($cache_path, $thumbnail_data);
|
||||
|
||||
// Enforce quota only for dynamic thumbnails
|
||||
if ($cache_type === 'dynamic') {
|
||||
static::_enforce_dynamic_quota();
|
||||
}
|
||||
|
||||
// Return thumbnail (serve from memory, don't re-read from disk)
|
||||
return Response::make($thumbnail_data, 200, [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'public, max-age=31536000', // 1 year
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preset thumbnail for image attachments
|
||||
*
|
||||
* Route: /_thumbnail/:key/preset/:preset_name
|
||||
*
|
||||
* Security: Checks file.thumbnail.authorize only
|
||||
*
|
||||
* @param string $key Attachment key
|
||||
* @param string $preset_name Preset name from config
|
||||
*/
|
||||
#[Route('/_thumbnail/:key/preset/:preset_name', methods: ['GET'])]
|
||||
#[Auth('Permission::anybody()')]
|
||||
public static function thumbnail_preset(Request $request, array $params = [])
|
||||
{
|
||||
$key = $params['key'] ?? null;
|
||||
$preset_name = $params['preset_name'] ?? null;
|
||||
|
||||
// Validate inputs
|
||||
if (!$key || !$preset_name) {
|
||||
abort(400, 'Invalid parameters');
|
||||
}
|
||||
|
||||
// Look up preset definition
|
||||
$presets = config('rsx.thumbnails.presets', []);
|
||||
if (!isset($presets[$preset_name])) {
|
||||
abort(404, "Thumbnail preset '{$preset_name}' not defined");
|
||||
}
|
||||
|
||||
$preset = $presets[$preset_name];
|
||||
$type = $preset['type'];
|
||||
$width = $preset['width'];
|
||||
$height = $preset['height'] ?? null;
|
||||
|
||||
// Find attachment
|
||||
$attachment = File_Attachment_Model::where('key', $key)->first();
|
||||
if (!$attachment) {
|
||||
abort(404, 'File not found');
|
||||
}
|
||||
|
||||
// Event: file.thumbnail.authorize (gate) - Check thumbnail access
|
||||
$thumbnail_auth = Rsx::trigger_gate('file.thumbnail.authorize', [
|
||||
'attachment' => $attachment,
|
||||
'user' => Session::get_user(),
|
||||
'request' => $request,
|
||||
]);
|
||||
|
||||
if ($thumbnail_auth !== true) {
|
||||
return $thumbnail_auth;
|
||||
}
|
||||
|
||||
// Generate cache filename
|
||||
$cache_filename = static::_get_cache_filename_preset(
|
||||
$preset_name,
|
||||
$attachment->file_storage->hash,
|
||||
$attachment->file_extension
|
||||
);
|
||||
|
||||
// Generate and serve
|
||||
return static::__generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
'preset',
|
||||
$cache_filename
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dynamic thumbnail for image attachments
|
||||
*
|
||||
* Route: /_thumbnail/:key/:type/:width/:height?
|
||||
*
|
||||
@@ -451,13 +590,14 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
$height = 10;
|
||||
}
|
||||
|
||||
// Enforce maximum dimensions
|
||||
if ($width > 256) {
|
||||
abort(400, 'Width must be between 10 and 256');
|
||||
// Enforce maximum dimensions (configurable, base resolution before 2x scaling)
|
||||
$max_size = config('rsx.thumbnails.max_dynamic_size', 800);
|
||||
if ($width > $max_size) {
|
||||
abort(400, "Width must be between 10 and {$max_size}");
|
||||
}
|
||||
|
||||
if ($height !== null && $height > 256) {
|
||||
abort(400, 'Height must be between 10 and 256');
|
||||
if ($height !== null && $height > $max_size) {
|
||||
abort(400, "Height must be between 10 and {$max_size}");
|
||||
}
|
||||
|
||||
// Find attachment
|
||||
@@ -477,42 +617,33 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
return $thumbnail_auth;
|
||||
}
|
||||
|
||||
// Get storage file
|
||||
$storage = $attachment->file_storage;
|
||||
if (!$storage || !file_exists($storage->get_full_path())) {
|
||||
abort(404, 'File not found on disk');
|
||||
}
|
||||
// Generate cache filename
|
||||
$cache_filename = static::_get_cache_filename_dynamic(
|
||||
$type,
|
||||
$width,
|
||||
$height ?? $width,
|
||||
$attachment->file_storage->hash,
|
||||
$attachment->file_extension
|
||||
);
|
||||
|
||||
// Try to generate thumbnail from actual file if possible
|
||||
if ($attachment->is_image()) {
|
||||
// Generate thumbnail from image
|
||||
$thumbnail_data = static::__generate_thumbnail(
|
||||
$storage->get_full_path(),
|
||||
$type,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
} else {
|
||||
// No source image or converter available - use icon-based thumbnail
|
||||
// NOTE: Not caching here because caching will be implemented at the
|
||||
// thumbnail storage level to allow deduplication across files of the same type
|
||||
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
|
||||
$attachment->file_extension,
|
||||
$width,
|
||||
$height ?? $width
|
||||
);
|
||||
}
|
||||
|
||||
// Return thumbnail
|
||||
return Response::make($thumbnail_data, 200, [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'public, max-age=31536000', // 1 year
|
||||
]);
|
||||
// Generate and serve
|
||||
return static::__generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
'dynamic',
|
||||
$cache_filename
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail image using Imagick
|
||||
*
|
||||
* Applies 2x resolution scaling for HiDPI displays, but caps at 66% of source
|
||||
* image dimensions to avoid excessive upscaling. Output aspect ratio always
|
||||
* matches requested dimensions, but actual resolution may be lower.
|
||||
*
|
||||
* @param string $source_path Source file path
|
||||
* @param string $type Thumbnail type: 'cover' or 'fit'
|
||||
* @param int $width Target width
|
||||
@@ -537,17 +668,37 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
$height = (int)round(($width / $original_width) * $original_height);
|
||||
}
|
||||
|
||||
// Constrain to 256x256 max
|
||||
if ($width > 256 || $height > 256) {
|
||||
if ($width > $height) {
|
||||
$height = (int)round(($height / $width) * 256);
|
||||
$width = 256;
|
||||
// Apply 2x scaling for HiDPI displays
|
||||
$target_width = $width * 2;
|
||||
$target_height = $height * 2;
|
||||
|
||||
// Calculate 66% threshold of source dimensions
|
||||
$max_width = (int)round($original_width * 0.66);
|
||||
$max_height = (int)round($original_height * 0.66);
|
||||
|
||||
// If target exceeds 66% of source on either dimension, cap at source dimensions
|
||||
if ($target_width > $max_width || $target_height > $max_height) {
|
||||
$target_width = $original_width;
|
||||
$target_height = $original_height;
|
||||
}
|
||||
|
||||
// Constrain to configured max (doubled for 2x scaling)
|
||||
// Default: 800 base → 1600 max after 2x
|
||||
$max_size = config('rsx.thumbnails.max_dynamic_size', 800) * 2;
|
||||
if ($target_width > $max_size || $target_height > $max_size) {
|
||||
if ($target_width > $target_height) {
|
||||
$target_height = (int)round(($target_height / $target_width) * $max_size);
|
||||
$target_width = $max_size;
|
||||
} else {
|
||||
$width = (int)round(($width / $height) * 256);
|
||||
$height = 256;
|
||||
$target_width = (int)round(($target_width / $target_height) * $max_size);
|
||||
$target_height = $max_size;
|
||||
}
|
||||
}
|
||||
|
||||
// Use target dimensions for actual generation
|
||||
$width = $target_width;
|
||||
$height = $target_height;
|
||||
|
||||
if ($type === 'cover') {
|
||||
// Cover: Fill area completely, crop excess
|
||||
// Calculate aspect ratios
|
||||
@@ -614,6 +765,175 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
return $thumbnail_data;
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// THUMBNAIL CACHING HELPERS
|
||||
// ============================================================================================
|
||||
|
||||
/**
|
||||
* Generate cache filename for preset thumbnail
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Format: {preset_name}_{hash}_{ext}.webp
|
||||
*
|
||||
* @param string $preset_name Preset name from config
|
||||
* @param string $hash File storage hash
|
||||
* @param string $extension File extension (normalized)
|
||||
* @return string Cache filename
|
||||
*/
|
||||
public static function _get_cache_filename_preset($preset_name, $hash, $extension)
|
||||
{
|
||||
return "{$preset_name}_{$hash}_{$extension}.webp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache filename for dynamic thumbnail
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Format: {type}_{width}x{height}_{hash}_{ext}.webp
|
||||
*
|
||||
* @param string $type Thumbnail type (cover or fit)
|
||||
* @param int $width Width in pixels
|
||||
* @param int $height Height in pixels
|
||||
* @param string $hash File storage hash
|
||||
* @param string $extension File extension (normalized)
|
||||
* @return string Cache filename
|
||||
*/
|
||||
public static function _get_cache_filename_dynamic($type, $width, $height, $hash, $extension)
|
||||
{
|
||||
return "{$type}_{$width}x{$height}_{$hash}_{$extension}.webp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full cache path for thumbnail
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* @param string $cache_type Either 'preset' or 'dynamic'
|
||||
* @param string $filename Cache filename
|
||||
* @return string Full filesystem path
|
||||
*/
|
||||
public static function _get_cache_path($cache_type, $filename)
|
||||
{
|
||||
return storage_path("rsx-thumbnails/{$cache_type}/{$filename}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve cached thumbnail from disk with optional mtime touch
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Handles race condition where file might be deleted between check and open.
|
||||
* Returns null if file cannot be opened (caller should regenerate).
|
||||
*
|
||||
* @param string $cache_path Full path to cached file
|
||||
* @return Response|null Response if successful, null if file unavailable
|
||||
*/
|
||||
protected static function _serve_cached_thumbnail($cache_path)
|
||||
{
|
||||
// Attempt to open file
|
||||
$handle = @fopen($cache_path, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
// File was deleted between exists check and open (race condition)
|
||||
return null;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
// Touch mtime if configured and old enough
|
||||
$touch_enabled = config('rsx.thumbnails.touch_on_read', true);
|
||||
$touch_interval = config('rsx.thumbnails.touch_interval', 600);
|
||||
|
||||
if ($touch_enabled && $touch_interval > 0) {
|
||||
$mtime = filemtime($cache_path);
|
||||
$age = time() - $mtime;
|
||||
|
||||
if ($age >= $touch_interval) {
|
||||
touch($cache_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Serve file
|
||||
return Response::file($cache_path, [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'public, max-age=31536000', // 1 year
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save generated thumbnail to cache
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Creates directory if it doesn't exist.
|
||||
*
|
||||
* @param string $cache_path Full path where thumbnail should be saved
|
||||
* @param string $thumbnail_data Binary WebP data
|
||||
* @return void
|
||||
*/
|
||||
public static function _save_thumbnail_to_cache($cache_path, $thumbnail_data)
|
||||
{
|
||||
// Ensure directory exists
|
||||
$dir = dirname($cache_path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
// Save thumbnail
|
||||
file_put_contents($cache_path, $thumbnail_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce quota for dynamic thumbnails
|
||||
*
|
||||
* Internal helper - do not call directly from application code.
|
||||
*
|
||||
* Scans dynamic thumbnail directory, calculates total size, and deletes
|
||||
* oldest files (by mtime) until under quota limit.
|
||||
*
|
||||
* Called synchronously after creating each new dynamic thumbnail.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function _enforce_dynamic_quota()
|
||||
{
|
||||
$max_bytes = config('rsx.thumbnails.quotas.dynamic_max_bytes');
|
||||
$dir = storage_path('rsx-thumbnails/dynamic/');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate current usage
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Over quota? Delete oldest first
|
||||
if ($total_size > $max_bytes) {
|
||||
// Sort by mtime ascending (oldest first)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
|
||||
if ($total_size <= $max_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// ICON UTILITIES
|
||||
// ============================================================================================
|
||||
|
||||
@@ -149,7 +149,7 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'file_attachments';
|
||||
protected $table = '_file_attachments';
|
||||
|
||||
/**
|
||||
* Get the physical file storage record
|
||||
@@ -246,7 +246,7 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail URL for this file
|
||||
* Get thumbnail URL for this file (dynamic thumbnails)
|
||||
*
|
||||
* @param string $type Thumbnail type: 'cover' or 'fit'
|
||||
* @param int $width Thumbnail width in pixels
|
||||
@@ -261,6 +261,27 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
return url("/_thumbnail/{$this->key}/{$type}/{$width}/{$height}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail URL for named preset
|
||||
*
|
||||
* Returns URL to preset thumbnail endpoint. Preset must be defined in
|
||||
* config/rsx.php under 'thumbnails.presets'.
|
||||
*
|
||||
* @param string $preset_name Preset name from config (e.g., 'profile', 'gallery')
|
||||
* @return string URL to thumbnail endpoint
|
||||
* @throws Exception if preset not defined
|
||||
*/
|
||||
public function get_thumbnail_url_preset($preset_name)
|
||||
{
|
||||
$presets = config('rsx.thumbnails.presets', []);
|
||||
|
||||
if (!isset($presets[$preset_name])) {
|
||||
throw new Exception("Thumbnail preset '{$preset_name}' not defined in config");
|
||||
}
|
||||
|
||||
return url("/_thumbnail/{$this->key}/preset/{$preset_name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon resource path for this file type
|
||||
*
|
||||
|
||||
@@ -40,7 +40,7 @@ class File_Storage_Model extends Rsx_Model_Abstract
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'file_storage';
|
||||
protected $table = '_file_storage';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Files;
|
||||
|
||||
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
use App\RSpade\Core\Files\File_Storage_Model;
|
||||
|
||||
/**
|
||||
* File_Thumbnail_Model - Thumbnail cache with deduplication
|
||||
*
|
||||
* Represents generated thumbnails as derivative artifacts of source files.
|
||||
* Thumbnails are deduplicated based on source file hash + parameters + mime type.
|
||||
*
|
||||
* Multiple File_Attachment_Model records sharing the same File_Storage_Model will
|
||||
* share the same thumbnail if they request the same thumbnail parameters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_ Database type hints - do not edit manually
|
||||
* Generated on: 2025-11-04 07:18:11
|
||||
* Table: file_thumbnails
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $source_storage_id
|
||||
* @property int $thumbnail_storage_id
|
||||
* @property string $params
|
||||
* @property mixed $detected_mime_type
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class File_Thumbnail_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
// Required static properties from parent abstract class
|
||||
public static $enums = [];
|
||||
public static $rel = [];
|
||||
|
||||
/**
|
||||
* The table associated with the model
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'file_thumbnails';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Column metadata for special handling
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $columnMeta = [];
|
||||
|
||||
/**
|
||||
* Get the source file storage record
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function source_storage()
|
||||
{
|
||||
return $this->belongsTo(File_Storage_Model::class, 'source_storage_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail file storage record
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function thumbnail_storage()
|
||||
{
|
||||
return $this->belongsTo(File_Storage_Model::class, 'thumbnail_storage_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decoded params JSON
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_params()
|
||||
{
|
||||
return json_decode($this->params, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate thumbnail cache key
|
||||
*
|
||||
* This key determines thumbnail deduplication. Thumbnails with the same
|
||||
* source file, parameters, and detected mime type will share the same
|
||||
* thumbnail storage.
|
||||
*
|
||||
* Formula: SHA-256(source_hash + params_json + detected_mime_type)
|
||||
*
|
||||
* @param string $source_hash The hash of the source File_Storage_Model
|
||||
* @param array $params Thumbnail parameters (width, height, crop, format, quality)
|
||||
* @param string $detected_mime Actual mime type detected from file content
|
||||
* @return string SHA-256 hash for thumbnail lookup
|
||||
*/
|
||||
public static function calculate_thumbnail_key($source_hash, array $params, $detected_mime)
|
||||
{
|
||||
// Normalize params to ensure consistent key generation
|
||||
ksort($params);
|
||||
$params_json = json_encode($params);
|
||||
|
||||
// Combine all factors that determine thumbnail uniqueness
|
||||
$key_string = $source_hash . $params_json . $detected_mime;
|
||||
|
||||
return hash('sha256', $key_string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing thumbnail or return null
|
||||
*
|
||||
* @param int $source_storage_id
|
||||
* @param array $params
|
||||
* @param string $detected_mime
|
||||
* @return static|null
|
||||
*/
|
||||
public static function find_thumbnail($source_storage_id, array $params, $detected_mime)
|
||||
{
|
||||
$params_json = json_encode($params);
|
||||
|
||||
return static::where('source_storage_id', $source_storage_id)
|
||||
->where('params', $params_json)
|
||||
->where('detected_mime_type', $detected_mime)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or find a thumbnail record
|
||||
*
|
||||
* @param File_Storage_Model $source_storage
|
||||
* @param File_Storage_Model $thumbnail_storage
|
||||
* @param array $params
|
||||
* @param string $detected_mime
|
||||
* @return static
|
||||
*/
|
||||
public static function create_thumbnail($source_storage, $thumbnail_storage, array $params, $detected_mime)
|
||||
{
|
||||
$params_json = json_encode($params);
|
||||
|
||||
$thumbnail = new static();
|
||||
$thumbnail->source_storage_id = $source_storage->id;
|
||||
$thumbnail->thumbnail_storage_id = $thumbnail_storage->id;
|
||||
$thumbnail->params = $params_json;
|
||||
$thumbnail->detected_mime_type = $detected_mime;
|
||||
$thumbnail->save();
|
||||
|
||||
return $thumbnail;
|
||||
}
|
||||
}
|
||||
252
app/RSpade/Core/Files/File_Thumbnail_Service.php
Executable file
252
app/RSpade/Core/Files/File_Thumbnail_Service.php
Executable file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Files;
|
||||
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
/**
|
||||
* File_Thumbnail_Service
|
||||
*
|
||||
* Business logic for thumbnail cache management including:
|
||||
* - Quota enforcement via LRU eviction
|
||||
* - Scheduled cleanup of preset thumbnails
|
||||
* - Statistics and reporting
|
||||
*
|
||||
* Storage Structure:
|
||||
* - storage/rsx-thumbnails/preset/ - Named preset thumbnails (100MB quota, scheduled cleanup)
|
||||
* - storage/rsx-thumbnails/dynamic/ - Dynamic ad-hoc thumbnails (50MB quota, synchronous cleanup)
|
||||
*
|
||||
* Cleanup Strategy:
|
||||
* - Preset: Runs every 30 minutes via scheduled task (this class)
|
||||
* - Dynamic: Enforced synchronously in File_Attachment_Controller after each generation
|
||||
* - Both: LRU eviction (oldest mtime deleted first)
|
||||
*/
|
||||
class File_Thumbnail_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
/**
|
||||
* Scheduled cleanup of preset thumbnail cache
|
||||
*
|
||||
* Runs every 30 minutes to enforce preset thumbnail quota.
|
||||
* Deletes oldest files (by mtime) until under configured limit.
|
||||
*
|
||||
* @param Task_Instance $task Task instance for logging
|
||||
* @param array $params Task parameters
|
||||
*/
|
||||
#[Task('Clean preset thumbnail cache (runs every 30 minutes)')]
|
||||
#[Schedule('*/30 * * * *')]
|
||||
public static function cleanup_preset_thumbnails(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$max_bytes = config('rsx.thumbnails.quotas.preset_max_bytes');
|
||||
$dir = storage_path('rsx-thumbnails/preset/');
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
// Directory doesn't exist yet - no cleanup needed
|
||||
$task->info('Preset thumbnail directory does not exist yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate current usage
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Not over quota? Nothing to do
|
||||
if ($total_size <= $max_bytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Over quota - delete oldest files until under limit
|
||||
// Sort by mtime ascending (oldest first)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
$deleted_count = 0;
|
||||
$freed_bytes = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
$deleted_count++;
|
||||
$freed_bytes += $file['size'];
|
||||
|
||||
if ($total_size <= $max_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Log cleanup statistics
|
||||
$freed_mb = round($freed_bytes / 1024 / 1024, 2);
|
||||
$task->info("Preset thumbnail cleanup: {$deleted_count} files deleted, {$freed_mb} MB freed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a thumbnail directory by enforcing quota
|
||||
*
|
||||
* Used by CLI commands for manual cleanup operations.
|
||||
*
|
||||
* @param string $type Either 'preset' or 'dynamic'
|
||||
* @return array [files_deleted, bytes_freed]
|
||||
*/
|
||||
public static function clean_directory($type)
|
||||
{
|
||||
$quota_key = $type . '_max_bytes';
|
||||
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
|
||||
$dir = storage_path("rsx-thumbnails/{$type}/");
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
// Calculate current usage
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Not over quota? Nothing to do
|
||||
if ($total_size <= $max_bytes) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
// Over quota - delete oldest first (LRU eviction)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
$deleted_count = 0;
|
||||
$freed_bytes = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
$deleted_count++;
|
||||
$freed_bytes += $file['size'];
|
||||
|
||||
if ($total_size <= $max_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [$deleted_count, $freed_bytes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for thumbnail cache directories
|
||||
*
|
||||
* @return array Statistics for preset and dynamic directories
|
||||
*/
|
||||
public static function get_statistics()
|
||||
{
|
||||
$stats = [
|
||||
'preset' => static::__get_directory_stats('preset'),
|
||||
'dynamic' => static::__get_directory_stats('dynamic'),
|
||||
];
|
||||
|
||||
// Add breakdown by preset name
|
||||
$stats['preset_breakdown'] = static::__get_preset_breakdown();
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a single directory
|
||||
*
|
||||
* @param string $type Either 'preset' or 'dynamic'
|
||||
* @return array Statistics including file count, total size, oldest/newest
|
||||
*/
|
||||
protected static function __get_directory_stats($type)
|
||||
{
|
||||
$dir = storage_path("rsx-thumbnails/{$type}/");
|
||||
$quota_key = $type . '_max_bytes';
|
||||
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [
|
||||
'exists' => false,
|
||||
'file_count' => 0,
|
||||
'total_bytes' => 0,
|
||||
'max_bytes' => $max_bytes,
|
||||
'usage_percent' => 0,
|
||||
'oldest' => null,
|
||||
'newest' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$files = glob($dir . '*.webp');
|
||||
$file_count = count($files);
|
||||
$total_bytes = 0;
|
||||
$oldest = null;
|
||||
$newest = null;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$total_bytes += filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
|
||||
if ($oldest === null || $mtime < $oldest) {
|
||||
$oldest = $mtime;
|
||||
}
|
||||
if ($newest === null || $mtime > $newest) {
|
||||
$newest = $mtime;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'exists' => true,
|
||||
'file_count' => $file_count,
|
||||
'total_bytes' => $total_bytes,
|
||||
'max_bytes' => $max_bytes,
|
||||
'usage_percent' => $max_bytes > 0 ? round(($total_bytes / $max_bytes) * 100, 1) : 0,
|
||||
'oldest' => $oldest,
|
||||
'newest' => $newest,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breakdown of preset thumbnails by preset name
|
||||
*
|
||||
* @return array Breakdown by preset name
|
||||
*/
|
||||
protected static function __get_preset_breakdown()
|
||||
{
|
||||
$dir = storage_path('rsx-thumbnails/preset/');
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$presets = config('rsx.thumbnails.presets', []);
|
||||
$breakdown = [];
|
||||
|
||||
// Initialize breakdown for each preset
|
||||
foreach (array_keys($presets) as $preset_name) {
|
||||
$breakdown[$preset_name] = [
|
||||
'file_count' => 0,
|
||||
'total_bytes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Count files for each preset
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$filename = basename($file);
|
||||
// Extract preset name from filename (format: presetname_hash_ext.webp)
|
||||
if (preg_match('/^([^_]+)_/', $filename, $matches)) {
|
||||
$preset_name = $matches[1];
|
||||
if (isset($breakdown[$preset_name])) {
|
||||
$breakdown[$preset_name]['file_count']++;
|
||||
$breakdown[$preset_name]['total_bytes'] += filesize($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
}
|
||||
364
app/RSpade/Core/Files/THUMBNAILS.md
Executable file
364
app/RSpade/Core/Files/THUMBNAILS.md
Executable file
@@ -0,0 +1,364 @@
|
||||
# Thumbnail Caching System - Developer Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The RSX thumbnail caching system provides a two-tier architecture designed to prevent cache pollution while maintaining flexibility. This document explains the implementation details for framework developers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two-Tier System
|
||||
|
||||
**Preset Thumbnails** (`storage/rsx-thumbnails/preset/`)
|
||||
- Developer-defined named sizes in config
|
||||
- Quota enforced via scheduled task (`rsx:thumbnails:clean --preset`)
|
||||
- Used for regular application features
|
||||
- Protected from abuse/spam
|
||||
|
||||
**Dynamic Thumbnails** (`storage/rsx-thumbnails/dynamic/`)
|
||||
- Ad-hoc sizes via URL parameters
|
||||
- Quota enforced synchronously after each generation
|
||||
- Used for edge cases and development
|
||||
- LRU eviction prevents unbounded growth
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
**Problem**: Traditional on-demand thumbnail generation allows users to request arbitrary sizes (e.g., by manipulating URL parameters), potentially generating thousands of cached thumbnails and consuming disk space.
|
||||
|
||||
**Solution**:
|
||||
1. Define all application thumbnail sizes as named presets
|
||||
2. Reference presets by name (`get_thumbnail_url_preset('profile')`)
|
||||
3. Dynamic thumbnails still available but discouraged and quota-limited
|
||||
|
||||
**Benefits**:
|
||||
- Prevents cache pollution from URL manipulation
|
||||
- Centralizes thumbnail size definitions
|
||||
- Enables cache warming via `rsx:thumbnails:generate`
|
||||
- Predictable cache size and cleanup
|
||||
|
||||
## Storage Structure
|
||||
|
||||
### Filename Format
|
||||
|
||||
**Preset**: `{preset_name}_{storage_hash}_{extension}.webp`
|
||||
- Example: `profile_abc123def456_jpg.webp`
|
||||
|
||||
**Dynamic**: `{type}_{width}x{height}_{storage_hash}_{extension}.webp`
|
||||
- Example: `cover_200x200_abc123def456_jpg.webp`
|
||||
|
||||
### Why Include Extension?
|
||||
|
||||
Edge case: Identical file content uploaded with different extensions (e.g., `document.zip` and `document.docx`) should render different icon-based thumbnails. Including the extension in the filename ensures separate caches.
|
||||
|
||||
## Caching Flow
|
||||
|
||||
### Request Processing
|
||||
|
||||
**Route**: `/_thumbnail/:key/:type/:width/:height` (dynamic) or `/_thumbnail/:key/preset/:preset_name` (preset)
|
||||
|
||||
**Common Flow** (via `generate_and_serve_thumbnail()`):
|
||||
1. Validate authorization (`file.thumbnail.authorize` event)
|
||||
2. Generate cache filename
|
||||
3. Check if cached file exists
|
||||
4. If exists:
|
||||
- Attempt to open (handles race condition)
|
||||
- If successful: touch mtime (if enabled), serve from disk
|
||||
- If failed: fall through to regeneration
|
||||
5. If not exists:
|
||||
- Generate thumbnail (Imagick for images, icon-based for others)
|
||||
- Save to cache
|
||||
- Enforce quota (dynamic only)
|
||||
- Serve from memory (no re-read)
|
||||
|
||||
### Race Condition Handling
|
||||
|
||||
**Scenario**: Between `file_exists()` check and `fopen()`, file might be deleted by quota enforcement in another request.
|
||||
|
||||
**Solution**: `_serve_cached_thumbnail()` returns `null` if file cannot be opened, triggering immediate regeneration. User never sees an error.
|
||||
|
||||
```php
|
||||
if (file_exists($cache_path)) {
|
||||
$response = static::_serve_cached_thumbnail($cache_path);
|
||||
if ($response !== null) {
|
||||
return $response; // Success
|
||||
}
|
||||
// File deleted - fall through to regeneration
|
||||
}
|
||||
|
||||
// Generate and serve
|
||||
$thumbnail_data = static::__generate_thumbnail(...);
|
||||
```
|
||||
|
||||
### LRU Tracking
|
||||
|
||||
**mtime Touching**:
|
||||
- On cache hit, touch file's mtime if older than `touch_interval` (default 10 minutes)
|
||||
- Prevents excessive filesystem writes while maintaining LRU accuracy
|
||||
- Used by quota enforcement to delete oldest (least recently used) files first
|
||||
|
||||
**Configuration**:
|
||||
```php
|
||||
'touch_on_read' => true, // Enable/disable
|
||||
'touch_interval' => 600, // Seconds (10 minutes)
|
||||
```
|
||||
|
||||
## Quota Enforcement
|
||||
|
||||
### Dynamic Thumbnails (Synchronous)
|
||||
|
||||
Called after each new dynamic thumbnail generation:
|
||||
|
||||
```php
|
||||
protected static function _enforce_dynamic_quota()
|
||||
{
|
||||
$max_bytes = config('rsx.thumbnails.quotas.dynamic_max_bytes');
|
||||
$dir = storage_path('rsx-thumbnails/dynamic/');
|
||||
|
||||
// Scan directory
|
||||
$total_size = 0;
|
||||
$files = [];
|
||||
foreach (glob($dir . '*.webp') as $file) {
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
$total_size += $size;
|
||||
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
|
||||
}
|
||||
|
||||
// Over quota?
|
||||
if ($total_size > $max_bytes) {
|
||||
// Sort by mtime (oldest first)
|
||||
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
|
||||
|
||||
// Delete oldest until under quota
|
||||
foreach ($files as $file) {
|
||||
unlink($file['path']);
|
||||
$total_size -= $file['size'];
|
||||
if ($total_size <= $max_bytes) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Performance**: Glob + stat operations are fast for directories with <10,000 files. Dynamic quota prevents unbounded growth.
|
||||
|
||||
### Preset Thumbnails (Scheduled)
|
||||
|
||||
Not enforced synchronously. Run via scheduled task:
|
||||
```bash
|
||||
php artisan rsx:thumbnails:clean --preset
|
||||
```
|
||||
|
||||
Same LRU algorithm as dynamic, but triggered externally.
|
||||
|
||||
## Helper Methods
|
||||
|
||||
### Internal Helpers (Underscore-Prefixed)
|
||||
|
||||
These are `public static` for artisan command access but prefixed with `_` to indicate they're internal:
|
||||
|
||||
**`_get_cache_filename_preset($preset_name, $hash, $extension)`**
|
||||
- Generates: `{preset_name}_{hash}_{ext}.webp`
|
||||
|
||||
**`_get_cache_filename_dynamic($type, $width, $height, $hash, $extension)`**
|
||||
- Generates: `{type}_{w}x{h}_{hash}_{ext}.webp`
|
||||
|
||||
**`_get_cache_path($cache_type, $filename)`**
|
||||
- Returns: `storage_path("rsx-thumbnails/{$cache_type}/{$filename}")`
|
||||
|
||||
**`_save_thumbnail_to_cache($cache_path, $thumbnail_data)`**
|
||||
- Writes WebP data to cache
|
||||
- Creates directory if needed
|
||||
|
||||
**`_serve_cached_thumbnail($cache_path)`**
|
||||
- Attempts to open file (race condition safe)
|
||||
- Touches mtime if configured
|
||||
- Returns Response or null
|
||||
|
||||
**`_enforce_dynamic_quota()`**
|
||||
- Scans dynamic directory
|
||||
- Deletes oldest files until under quota
|
||||
|
||||
### Why Public?
|
||||
|
||||
Artisan commands (`Thumbnails_Generate_Command`, etc.) need access to these helpers. Making them public with `_` prefix indicates "internal use only - don't call from application code."
|
||||
|
||||
## DRY Implementation
|
||||
|
||||
**Single Generation Function**:
|
||||
```php
|
||||
protected static function generate_and_serve_thumbnail(
|
||||
$attachment,
|
||||
$type,
|
||||
$width,
|
||||
$height,
|
||||
$cache_type,
|
||||
$cache_filename
|
||||
) {
|
||||
// Common logic for both preset and dynamic
|
||||
// Check cache -> Generate if needed -> Save -> Enforce quota -> Serve
|
||||
}
|
||||
```
|
||||
|
||||
**Route Methods Are Thin Wrappers**:
|
||||
```php
|
||||
public static function thumbnail_preset(Request $request, array $params = [])
|
||||
{
|
||||
// Parse preset params
|
||||
// Generate cache filename
|
||||
// Call common function
|
||||
return static::generate_and_serve_thumbnail(...);
|
||||
}
|
||||
|
||||
public static function thumbnail(Request $request, array $params = [])
|
||||
{
|
||||
// Parse dynamic params
|
||||
// Generate cache filename
|
||||
// Call common function
|
||||
return static::generate_and_serve_thumbnail(...);
|
||||
}
|
||||
```
|
||||
|
||||
This eliminates duplication while keeping route-specific validation separate.
|
||||
|
||||
## Artisan Commands
|
||||
|
||||
### Thumbnails_Clean_Command
|
||||
|
||||
Enforces quotas for preset and/or dynamic thumbnails.
|
||||
|
||||
**Implementation**:
|
||||
- Scans directory, builds array of [path, size, mtime]
|
||||
- Sorts by mtime ascending (oldest first)
|
||||
- Deletes files until under quota
|
||||
- Reports statistics
|
||||
|
||||
**Flags**: `--preset`, `--dynamic`, `--all` (default)
|
||||
|
||||
### Thumbnails_Generate_Command
|
||||
|
||||
Pre-generates preset thumbnails for attachments.
|
||||
|
||||
**Implementation**:
|
||||
- Queries File_Attachment_Model (optionally filtered by key)
|
||||
- For each attachment/preset combination:
|
||||
- Generate cache filename
|
||||
- Skip if already cached
|
||||
- Generate thumbnail
|
||||
- Save to cache
|
||||
- No quota enforcement (manual command)
|
||||
|
||||
**Use Cases**:
|
||||
- Cache warming after deployment
|
||||
- Background generation for new uploads
|
||||
- Regenerating after changing preset sizes
|
||||
|
||||
### Thumbnails_Stats_Command
|
||||
|
||||
Displays cache usage statistics.
|
||||
|
||||
**Implementation**:
|
||||
- Scans both directories
|
||||
- Calculates: file count, total size, quota percentage, oldest/newest mtime
|
||||
- For presets: breaks down by preset name (extracted from filename)
|
||||
|
||||
## WebP Output
|
||||
|
||||
All thumbnails are WebP format for optimal size/quality:
|
||||
|
||||
```php
|
||||
$image->setImageFormat('webp');
|
||||
$image->setImageCompressionQuality(85);
|
||||
$thumbnail_data = $image->getImageBlob();
|
||||
```
|
||||
|
||||
**Why WebP?**
|
||||
- Superior compression vs JPEG/PNG
|
||||
- Supports transparency (for 'fit' thumbnails)
|
||||
- Universal browser support (96%+ as of 2024)
|
||||
|
||||
## Non-Image Files
|
||||
|
||||
Icon-based thumbnails for PDFs, documents, etc.:
|
||||
|
||||
```php
|
||||
if ($attachment->is_image()) {
|
||||
$thumbnail_data = static::__generate_thumbnail(...);
|
||||
} else {
|
||||
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
|
||||
$attachment->file_extension,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Icons are rendered as WebP images for consistency.
|
||||
|
||||
## Security
|
||||
|
||||
**Authorization**: All thumbnail endpoints check `file.thumbnail.authorize` event gate. Implement handlers in `/rsx/handlers/` to control access.
|
||||
|
||||
**Dimension Limits**: Dynamic thumbnails enforce 10-256 pixel limits to prevent abuse. Preset thumbnails have no limits (developer-controlled).
|
||||
|
||||
**Extension Validation**: File extensions are normalized (lowercase, jpeg→jpg) before cache filename generation.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration in `/system/config/rsx.php` under `thumbnails` key:
|
||||
|
||||
```php
|
||||
'thumbnails' => [
|
||||
'presets' => [
|
||||
'profile' => ['type' => 'cover', 'width' => 200, 'height' => 200],
|
||||
// ...
|
||||
],
|
||||
'quotas' => [
|
||||
'preset_max_bytes' => 100 * 1024 * 1024, // 100MB
|
||||
'dynamic_max_bytes' => 50 * 1024 * 1024, // 50MB
|
||||
],
|
||||
'touch_on_read' => env('THUMBNAILS_TOUCH_ON_READ', true),
|
||||
'touch_interval' => env('THUMBNAILS_TOUCH_INTERVAL', 600),
|
||||
],
|
||||
```
|
||||
|
||||
## File_Thumbnail_Model Removal
|
||||
|
||||
**Previous Design**: Database table tracking thumbnails with source/thumbnail storage relationships.
|
||||
|
||||
**Why Removed**:
|
||||
- Filesystem is sufficient for caching
|
||||
- mtime provides LRU info
|
||||
- No need for DB overhead
|
||||
- Simpler, faster, less to maintain
|
||||
|
||||
**Migration**: `2025_11_16_093331_drop_file_thumbnails_table.php` drops the table.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Preset thumbnail generation and caching
|
||||
- [ ] Dynamic thumbnail generation and caching
|
||||
- [ ] Cache hits serve from disk
|
||||
- [ ] Cache misses generate and cache
|
||||
- [ ] Race condition handling (file deleted between check and open)
|
||||
- [ ] Dynamic quota enforcement deletes oldest first
|
||||
- [ ] mtime touching respects config and interval
|
||||
- [ ] Preset/dynamic survive `rsx:clean`
|
||||
- [ ] Same hash + different extension creates separate thumbnails
|
||||
- [ ] Invalid preset name throws exception
|
||||
- [ ] All generated thumbnails are WebP
|
||||
- [ ] `rsx:thumbnails:clean` enforces quotas
|
||||
- [ ] `rsx:thumbnails:generate` pre-generates correctly
|
||||
- [ ] `rsx:thumbnails:stats` shows accurate data
|
||||
- [ ] Icon-based thumbnails for non-images work
|
||||
- [ ] Browser cache headers correct (1 year)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Not in current implementation but planned:
|
||||
|
||||
- **Animated thumbnails**: Extract first frame from GIF/WebP/video
|
||||
- **Video thumbnails**: Frame extraction via FFmpeg
|
||||
- **PDF thumbnails**: First page via Imagick
|
||||
- **CDN integration**: Upload thumbnails to CDN storage
|
||||
- **Per-preset quotas**: Different limits for different presets
|
||||
- **Background generation**: Queue-based thumbnail generation
|
||||
- **Analytics**: DB-backed usage tracking (optional)
|
||||
@@ -186,6 +186,11 @@ class Ajax {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle flash_alerts from server
|
||||
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
|
||||
Server_Side_Flash.process(response.flash_alerts);
|
||||
}
|
||||
|
||||
// Check if the response was successful
|
||||
if (response._success === true) {
|
||||
// @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value
|
||||
|
||||
@@ -249,7 +249,7 @@ class Debugger {
|
||||
Debugger._console_timer = null;
|
||||
|
||||
try {
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_console_messages'), { messages: messages });
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller::log_console_messages'), { messages: messages });
|
||||
} catch (error) {
|
||||
// Silently fail - don't create error loop
|
||||
console.error('Failed to send console_debug messages to server:', error);
|
||||
@@ -270,7 +270,7 @@ class Debugger {
|
||||
Debugger._error_batch_count++;
|
||||
|
||||
try {
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_browser_errors'), { errors: errors });
|
||||
return Ajax.call(Rsx.Route('Debugger_Controller::log_browser_errors'), { errors: errors });
|
||||
} catch (error) {
|
||||
// Silently fail - don't create error loop
|
||||
console.error('Failed to send browser errors to server:', error);
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
* if (Rsx.is_dev()) { console.log('Development mode'); }
|
||||
*
|
||||
* // Route generation
|
||||
* const url = Rsx.Route('Controller', 'action').url();
|
||||
* const url = Rsx.Route('Controller::action').url();
|
||||
*
|
||||
* // Unique IDs
|
||||
* const uniqueId = Rsx.uid(); // e.g., "rsx_1234567890_1"
|
||||
@@ -134,70 +134,99 @@ class Rsx {
|
||||
static _routes = {};
|
||||
|
||||
/**
|
||||
* Define routes from bundled data
|
||||
* Called by generated JavaScript in bundles
|
||||
* Calculate scope key from current environment.
|
||||
*
|
||||
* Scope key is a hash key which includes the current value of the session, user, site, and build keys.
|
||||
* Data hashed with this key will be scoped to the current logged in user, and will be invalidated if
|
||||
* the user logs out or the application source code is updated / redeployed / etc.
|
||||
*
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
static _define_routes(routes) {
|
||||
// Merge routes into the global route storage
|
||||
for (const class_name in routes) {
|
||||
if (!Rsx._routes[class_name]) {
|
||||
Rsx._routes[class_name] = {};
|
||||
}
|
||||
for (const method_name in routes[class_name]) {
|
||||
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
|
||||
}
|
||||
static scope_key() {
|
||||
const parts = [];
|
||||
|
||||
// Get session hash (hashed on server for non-reversible scoping)
|
||||
if (window.rsxapp?.session_hash) {
|
||||
parts.push(window.rsxapp.session_hash);
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
if (window.rsxapp?.user?.id) {
|
||||
parts.push(window.rsxapp.user.id);
|
||||
}
|
||||
|
||||
// Get site ID
|
||||
if (window.rsxapp?.site?.id) {
|
||||
parts.push(window.rsxapp.site.id);
|
||||
}
|
||||
|
||||
// Get build key
|
||||
if (window.rsxapp?.build_key) {
|
||||
parts.push(window.rsxapp.build_key);
|
||||
}
|
||||
|
||||
return parts.join('_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL for a controller route
|
||||
* Generate URL for a controller route or SPA action
|
||||
*
|
||||
* This method generates URLs for controller actions by looking up route patterns
|
||||
* and replacing parameters. It handles both regular routes and Ajax endpoints.
|
||||
* This method generates URLs by looking up route patterns and replacing parameters.
|
||||
* It handles controller routes, SPA action routes, and Ajax endpoints.
|
||||
*
|
||||
* If the route is not found in the route definitions, a default pattern is used:
|
||||
* `/_/{controller}/{action}` with all parameters appended as query strings.
|
||||
*
|
||||
* Usage examples:
|
||||
* ```javascript
|
||||
* // Simple route without parameters (defaults to 'index' action)
|
||||
* // Controller route (defaults to 'index' method)
|
||||
* const url = Rsx.Route('Frontend_Index_Controller');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with explicit action
|
||||
* const url = Rsx.Route('Frontend_Index_Controller', 'index');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', 123);
|
||||
* // Controller route with explicit method
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller::view', 123);
|
||||
* // Returns: /clients/view/123
|
||||
*
|
||||
* // SPA action route
|
||||
* const url = Rsx.Route('Contacts_Index_Action');
|
||||
* // Returns: /contacts
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* const url = Rsx.Route('Contacts_View_Action', 123);
|
||||
* // Returns: /contacts/123
|
||||
*
|
||||
* // Route with named parameters (object)
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {id: 'C001'});
|
||||
* // Returns: /clients/view/C001
|
||||
* const url = Rsx.Route('Contacts_View_Action', {id: 'C001'});
|
||||
* // Returns: /contacts/C001
|
||||
*
|
||||
* // Route with required and query parameters
|
||||
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {
|
||||
* const url = Rsx.Route('Contacts_View_Action', {
|
||||
* id: 'C001',
|
||||
* tab: 'history'
|
||||
* });
|
||||
* // Returns: /clients/view/C001?tab=history
|
||||
*
|
||||
* // Route not found - uses default pattern
|
||||
* const url = Rsx.Route('Unimplemented_Controller', 'some_action', {foo: 'bar'});
|
||||
* // Returns: /_/Unimplemented_Controller/some_action?foo=bar
|
||||
* // Returns: /contacts/C001?tab=history
|
||||
*
|
||||
* // Placeholder route
|
||||
* const url = Rsx.Route('Future_Controller', '#index');
|
||||
* const url = Rsx.Route('Future_Controller::#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param {string} class_name The controller class name (e.g., 'User_Controller')
|
||||
* @param {string} [action_name='index'] The action/method name (defaults to 'index'). Use '#action' for placeholders.
|
||||
* @param {string} action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
|
||||
* @param {number|Object} [params=null] Route parameters. Integer sets 'id', object provides named params.
|
||||
* @returns {string} The generated URL
|
||||
*/
|
||||
static Route(class_name, action_name = 'index', params = null) {
|
||||
static Route(action, params = null) {
|
||||
// Parse action into class_name and action_name
|
||||
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
|
||||
let class_name, action_name;
|
||||
if (action.includes('::')) {
|
||||
[class_name, action_name] = action.split('::', 2);
|
||||
} else {
|
||||
class_name = action;
|
||||
action_name = 'index';
|
||||
}
|
||||
|
||||
// Normalize params to object
|
||||
let params_obj = {};
|
||||
if (typeof params === 'number') {
|
||||
@@ -213,13 +242,19 @@ class Rsx {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Check if route exists in definitions
|
||||
// Check if route exists in PHP controller definitions
|
||||
let pattern;
|
||||
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
|
||||
pattern = Rsx._routes[class_name][action_name];
|
||||
} else {
|
||||
// Route not found - use default pattern /_/{controller}/{action}
|
||||
pattern = `/_/${class_name}/${action_name}`;
|
||||
// Not found in PHP routes - check if it's a SPA action
|
||||
pattern = Rsx._try_spa_action_route(class_name);
|
||||
|
||||
if (!pattern) {
|
||||
// Route not found - use default pattern /_/{controller}/{action}
|
||||
// For SPA actions, action_name defaults to 'index'
|
||||
pattern = `/_/${class_name}/${action_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate URL from pattern
|
||||
@@ -287,6 +322,60 @@ class Rsx {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a route pattern for a SPA action class
|
||||
* Returns the route pattern or null if not found
|
||||
*
|
||||
* @param {string} class_name The action class name
|
||||
* @returns {string|null} The route pattern or null
|
||||
*/
|
||||
static _try_spa_action_route(class_name) {
|
||||
// Get all classes from manifest
|
||||
const all_classes = Manifest.get_all_classes();
|
||||
|
||||
// Find the class by name
|
||||
for (const class_info of all_classes) {
|
||||
if (class_info.class_name === class_name) {
|
||||
const class_object = class_info.class_object;
|
||||
|
||||
// Check if it's a SPA action (has Spa_Action in prototype chain)
|
||||
if (typeof Spa_Action !== 'undefined' &&
|
||||
class_object.prototype instanceof Spa_Action) {
|
||||
|
||||
// Get route patterns from decorator metadata
|
||||
const routes = class_object._spa_routes || [];
|
||||
|
||||
if (routes.length > 0) {
|
||||
// Return the first route pattern
|
||||
return routes[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Found the class but it's not a SPA action or has no routes
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Class not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define routes from bundled data
|
||||
* Called by generated JavaScript in bundles
|
||||
*/
|
||||
static _define_routes(routes) {
|
||||
// Merge routes into the global route storage
|
||||
for (const class_name in routes) {
|
||||
if (!Rsx._routes[class_name]) {
|
||||
Rsx._routes[class_name] = {};
|
||||
}
|
||||
for (const method_name in routes[class_name]) {
|
||||
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Call a specific method on all classes that have it
|
||||
* Collects promises from return values and waits for all to resolve
|
||||
@@ -450,17 +539,17 @@ class Rsx {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all page state from URL hash
|
||||
* Get all hash state from URL hash
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* const state = Rsx.get_all_page_state();
|
||||
* const state = Rsx.url_hash_get_all();
|
||||
* // Returns: {dg_page: '2', dg_sort: 'name'}
|
||||
* ```
|
||||
*
|
||||
* @returns {Object} All hash parameters as key-value pairs
|
||||
*/
|
||||
static get_all_page_state() {
|
||||
static url_hash_get_all() {
|
||||
return Rsx._parse_hash();
|
||||
}
|
||||
|
||||
@@ -469,14 +558,14 @@ class Rsx {
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* const page = Rsx.get_page_state('dg_page');
|
||||
* const page = Rsx.url_hash_get('dg_page');
|
||||
* // Returns: '2' or null if not set
|
||||
* ```
|
||||
*
|
||||
* @param {string} key The key to retrieve
|
||||
* @returns {string|null} The value or null if not found
|
||||
*/
|
||||
static get_page_state(key) {
|
||||
static url_hash_get(key) {
|
||||
const state = Rsx._parse_hash();
|
||||
return state[key] ?? null;
|
||||
}
|
||||
@@ -486,16 +575,16 @@ class Rsx {
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* Rsx.set_page_state('dg_page', 2);
|
||||
* Rsx.url_hash_set_single('dg_page', 2);
|
||||
* // URL becomes: http://example.com/page#dg_page=2
|
||||
*
|
||||
* Rsx.set_page_state('dg_page', null); // Remove key
|
||||
* Rsx.url_hash_set_single('dg_page', null); // Remove key
|
||||
* ```
|
||||
*
|
||||
* @param {string} key The key to set
|
||||
* @param {string|number|null} value The value (null/empty removes the key)
|
||||
*/
|
||||
static set_page_state(key, value) {
|
||||
static url_hash_set_single(key, value) {
|
||||
const state = Rsx._parse_hash();
|
||||
|
||||
// Update or remove the key
|
||||
@@ -516,13 +605,15 @@ class Rsx {
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* Rsx.set_all_page_state({dg_page: 2, dg_sort: 'name'});
|
||||
* Rsx.url_hash_set({dg_page: 2, dg_sort: 'name'});
|
||||
* // URL becomes: http://example.com/page#dg_page=2&dg_sort=name
|
||||
*
|
||||
* Rsx.url_hash_set({dg_page: null}); // Remove key from hash
|
||||
* ```
|
||||
*
|
||||
* @param {Object} new_state Object with key-value pairs to set
|
||||
* @param {Object} new_state Object with key-value pairs to set (null removes key)
|
||||
*/
|
||||
static set_all_page_state(new_state) {
|
||||
static url_hash_set(new_state) {
|
||||
const state = Rsx._parse_hash();
|
||||
|
||||
// Merge new state
|
||||
@@ -601,7 +692,7 @@ class Rsx {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h5>Validation Errors:</h5>
|
||||
<ul class="mb-0">
|
||||
${error_list.map(err => `<li>${Rsx._escape_html(err)}</li>`).join('')}
|
||||
${error_list.map((err) => `<li>${Rsx._escape_html(err)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
357
app/RSpade/Core/Js/Rsx_Storage.js
Executable file
357
app/RSpade/Core/Js/Rsx_Storage.js
Executable file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Rsx_Storage - Scoped browser storage helper with graceful degradation
|
||||
*
|
||||
* Provides safe, scoped access to sessionStorage and localStorage with automatic
|
||||
* handling of unavailable storage, quota exceeded errors, and scope invalidation.
|
||||
*
|
||||
* Key Features:
|
||||
* - **Automatic scoping**: All keys scoped by cookie, user, site, and build version
|
||||
* - **Graceful degradation**: Returns null when storage unavailable (private browsing, etc.)
|
||||
* - **Quota management**: Auto-clears storage when full and retries operation
|
||||
* - **Scope validation**: Clears storage when scope changes (user logout, build update, etc.)
|
||||
* - **Developer-friendly keys**: Scoped suffix allows easy inspection in dev tools
|
||||
*
|
||||
* Scoping Strategy:
|
||||
* Storage is scoped by combining:
|
||||
* - `window.rsxapp.session_hash` (hashed session identifier, non-reversible)
|
||||
* - `window.rsxapp.user.id` (current user)
|
||||
* - `window.rsxapp.site.id` (current site)
|
||||
* - `window.rsxapp.build_key` (application version)
|
||||
*
|
||||
* This scope is stored in `_rsx_scope_key`. If the scope changes between page loads,
|
||||
* all RSpade keys are cleared to prevent stale data from different sessions/users/builds.
|
||||
*
|
||||
* Key Format:
|
||||
* Keys are stored as: `rsx::developer_key::scope_suffix`
|
||||
* Example: `rsx::flash_queue::abc123_42_1_v2.1.0`
|
||||
*
|
||||
* The `rsx::` prefix identifies RSpade keys, allowing safe clearing of only our keys
|
||||
* without affecting other JavaScript libraries. This enables transparent coexistence
|
||||
* with third-party libraries that also use browser storage.
|
||||
*
|
||||
* Quota Exceeded Handling:
|
||||
* When storage quota is exceeded during a set operation, only RSpade keys (prefixed with
|
||||
* `rsx::`) are cleared, preserving other libraries' data. The operation is then retried
|
||||
* once. This ensures the application continues functioning even when storage is full.
|
||||
*
|
||||
* Usage:
|
||||
* // Session storage (cleared on tab close)
|
||||
* Rsx_Storage.session_set('user_preferences', {theme: 'dark'});
|
||||
* const prefs = Rsx_Storage.session_get('user_preferences');
|
||||
* Rsx_Storage.session_remove('user_preferences');
|
||||
*
|
||||
* // Local storage (persists across sessions)
|
||||
* Rsx_Storage.local_set('cached_data', {items: [...]});
|
||||
* const data = Rsx_Storage.local_get('cached_data');
|
||||
* Rsx_Storage.local_remove('cached_data');
|
||||
*
|
||||
* IMPORTANT - Volatile Storage:
|
||||
* Storage can be cleared at any time due to:
|
||||
* - User clearing browser data
|
||||
* - Private browsing mode restrictions
|
||||
* - Quota exceeded errors
|
||||
* - Scope changes (logout, build update, session change)
|
||||
* - Browser storage unavailable
|
||||
*
|
||||
* Therefore, NEVER store critical data that impacts application functionality.
|
||||
* Only store:
|
||||
* - Cached data (performance optimization)
|
||||
* - UI state (convenience, not required)
|
||||
* - Transient messages (flash alerts, notifications)
|
||||
*
|
||||
* If the data is required for the application to function, store it server-side.
|
||||
*/
|
||||
class Rsx_Storage {
|
||||
static _scope_suffix = null;
|
||||
static _session_available = null;
|
||||
static _local_available = null;
|
||||
|
||||
/**
|
||||
* Initialize storage system and validate scope
|
||||
* Called automatically on first access
|
||||
* @private
|
||||
*/
|
||||
static _init() {
|
||||
// Check if already initialized
|
||||
if (this._scope_suffix !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check storage availability
|
||||
this._session_available = this._is_storage_available('sessionStorage');
|
||||
this._local_available = this._is_storage_available('localStorage');
|
||||
|
||||
// Calculate current scope suffix
|
||||
this._scope_suffix = Rsx.scope_key();
|
||||
|
||||
// Validate scope for both storages
|
||||
if (this._session_available) {
|
||||
this._validate_scope(sessionStorage, 'session');
|
||||
}
|
||||
if (this._local_available) {
|
||||
this._validate_scope(localStorage, 'local');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a storage type is available
|
||||
* @param {string} type - 'sessionStorage' or 'localStorage'
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
static _is_storage_available(type) {
|
||||
try {
|
||||
const storage = window[type];
|
||||
const test = '__rsx_storage_test__';
|
||||
storage.setItem(test, test);
|
||||
storage.removeItem(test);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate storage scope and clear RSpade keys if changed
|
||||
* Only clears keys prefixed with 'rsx::' to preserve other libraries' data
|
||||
* @param {Storage} storage - sessionStorage or localStorage
|
||||
* @param {string} type - 'session' or 'local' (for logging)
|
||||
* @private
|
||||
*/
|
||||
static _validate_scope(storage, type) {
|
||||
try {
|
||||
const stored_scope = storage.getItem('_rsx_scope_key');
|
||||
|
||||
// If scope key exists and has changed, clear only RSpade keys
|
||||
if (stored_scope !== null && stored_scope !== this._scope_suffix) {
|
||||
console.log(`[Rsx_Storage] Scope changed for ${type}Storage, clearing RSpade keys only:`, {
|
||||
old_scope: stored_scope,
|
||||
new_scope: this._scope_suffix,
|
||||
});
|
||||
this._clear_rsx_keys(storage);
|
||||
storage.setItem('_rsx_scope_key', this._scope_suffix);
|
||||
} else if (stored_scope === null) {
|
||||
// First time RSpade is using this storage - just set the key, don't clear
|
||||
console.log(`[Rsx_Storage] Initializing scope for ${type}Storage (first use):`, {
|
||||
new_scope: this._scope_suffix,
|
||||
});
|
||||
storage.setItem('_rsx_scope_key', this._scope_suffix);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Rsx_Storage] Failed to validate scope for ${type}Storage:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only RSpade keys from storage (keys starting with 'rsx::')
|
||||
* Preserves keys from other libraries
|
||||
* @param {Storage} storage - sessionStorage or localStorage
|
||||
* @private
|
||||
*/
|
||||
static _clear_rsx_keys(storage) {
|
||||
const keys_to_remove = [];
|
||||
|
||||
// Collect all RSpade keys
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key && key.startsWith('rsx::')) { // @JS-DEFENSIVE-01-EXCEPTION - Browser API storage.key(i) can return null when i >= storage.length
|
||||
keys_to_remove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove collected keys
|
||||
keys_to_remove.forEach((key) => {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch (e) {
|
||||
console.error('[Rsx_Storage] Failed to remove key:', key, e);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Rsx_Storage] Cleared ${keys_to_remove.length} RSpade keys`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build scoped key with RSpade namespace prefix
|
||||
* @param {string} key - Developer-provided key
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
static _build_key(key) {
|
||||
return `rsx::${key}::${this._scope_suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in sessionStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store (will be JSON serialized)
|
||||
*/
|
||||
static session_set(key, value) {
|
||||
this._init();
|
||||
|
||||
if (!this._session_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._set_item(sessionStorage, key, value, 'session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item from sessionStorage
|
||||
* @param {string} key - Storage key
|
||||
* @returns {*|null} Parsed value or null if not found/unavailable
|
||||
*/
|
||||
static session_get(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._session_available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._get_item(sessionStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from sessionStorage
|
||||
* @param {string} key - Storage key
|
||||
*/
|
||||
static session_remove(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._session_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._remove_item(sessionStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store (will be JSON serialized)
|
||||
*/
|
||||
static local_set(key, value) {
|
||||
this._init();
|
||||
|
||||
if (!this._local_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._set_item(localStorage, key, value, 'local');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @returns {*|null} Parsed value or null if not found/unavailable
|
||||
*/
|
||||
static local_get(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._local_available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._get_item(localStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
*/
|
||||
static local_remove(key) {
|
||||
this._init();
|
||||
|
||||
if (!this._local_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._remove_item(localStorage, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal set implementation with scope validation and quota handling
|
||||
* @param {Storage} storage
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
* @param {string} type - 'session' or 'local' (for logging)
|
||||
* @private
|
||||
*/
|
||||
static _set_item(storage, key, value, type) {
|
||||
// Validate scope before every write
|
||||
this._validate_scope(storage, type);
|
||||
|
||||
const scoped_key = this._build_key(key);
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
// Check size - skip if larger than 1MB
|
||||
const size_bytes = new Blob([serialized]).size;
|
||||
const size_mb = size_bytes / (1024 * 1024);
|
||||
|
||||
if (size_mb > 1) {
|
||||
console.warn(`[Rsx_Storage] Skipping storage for key "${key}" - data too large (${size_mb.toFixed(2)} MB, limit 1 MB)`);
|
||||
return;
|
||||
}
|
||||
|
||||
storage.setItem(scoped_key, serialized);
|
||||
} catch (e) {
|
||||
// Check if quota exceeded
|
||||
if (e.name === 'QuotaExceededError' || e.code === 22) {
|
||||
console.warn(`[Rsx_Storage] Quota exceeded for ${type}Storage, clearing RSpade keys and retrying`);
|
||||
|
||||
// Clear only RSpade keys and retry once
|
||||
this._clear_rsx_keys(storage);
|
||||
storage.setItem('_rsx_scope_key', this._scope_suffix);
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
storage.setItem(scoped_key, serialized);
|
||||
} catch (retry_error) {
|
||||
console.error(`[Rsx_Storage] Failed to set item after clearing RSpade keys from ${type}Storage:`, retry_error);
|
||||
}
|
||||
} else {
|
||||
console.error(`[Rsx_Storage] Failed to set item in ${type}Storage:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal get implementation
|
||||
* @param {Storage} storage
|
||||
* @param {string} key
|
||||
* @returns {*|null}
|
||||
* @private
|
||||
*/
|
||||
static _get_item(storage, key) {
|
||||
const scoped_key = this._build_key(key);
|
||||
|
||||
try {
|
||||
const serialized = storage.getItem(scoped_key);
|
||||
if (serialized === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(serialized);
|
||||
} catch (e) {
|
||||
console.error('[Rsx_Storage] Failed to get item:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal remove implementation
|
||||
* @param {Storage} storage
|
||||
* @param {string} key
|
||||
* @private
|
||||
*/
|
||||
static _remove_item(storage, key) {
|
||||
const scoped_key = this._build_key(key);
|
||||
|
||||
try {
|
||||
storage.removeItem(scoped_key);
|
||||
} catch (e) {
|
||||
console.error('[Rsx_Storage] Failed to remove item:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,3 +459,79 @@ function csv_to_array_trim(str_csv) {
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// URL UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert a full URL to short URL by removing protocol
|
||||
*
|
||||
* Strips http:// or https:// from the beginning of the URL if present.
|
||||
* Leaves the URL alone if it doesn't start with either protocol.
|
||||
* Removes trailing slash if there is no path.
|
||||
*
|
||||
* @param {string|null} url - URL to convert
|
||||
* @returns {string|null} Short URL without protocol
|
||||
*/
|
||||
function full_url_to_short_url(url) {
|
||||
if (url === null || url === undefined || url === '') {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Convert to string if needed
|
||||
url = String(url);
|
||||
|
||||
// Remove http:// or https:// from the beginning (case-insensitive)
|
||||
if (url.toLowerCase().indexOf('http://') === 0) {
|
||||
url = url.substring(7);
|
||||
} else if (url.toLowerCase().indexOf('https://') === 0) {
|
||||
url = url.substring(8);
|
||||
}
|
||||
|
||||
// Remove trailing slash if there is no path (just domain)
|
||||
// Check if URL is just domain with trailing slash (no path after slash)
|
||||
if (url.endsWith('/') && (url.match(/\//g) || []).length === 1) {
|
||||
url = url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a short URL to full URL by adding protocol
|
||||
*
|
||||
* Adds http:// to the beginning of the URL if it lacks a protocol.
|
||||
* Leaves URLs with existing http:// or https:// unchanged.
|
||||
* Adds trailing slash if there is no path.
|
||||
*
|
||||
* @param {string|null} url - URL to convert
|
||||
* @returns {string|null} Full URL with protocol
|
||||
*/
|
||||
function short_url_to_full_url(url) {
|
||||
if (url === null || url === undefined || url === '') {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Convert to string if needed
|
||||
url = String(url);
|
||||
|
||||
let full_url;
|
||||
|
||||
// Check if URL already has a protocol (case-insensitive)
|
||||
if (url.toLowerCase().indexOf('http://') === 0 || url.toLowerCase().indexOf('https://') === 0) {
|
||||
full_url = url;
|
||||
} else {
|
||||
// Add http:// protocol
|
||||
full_url = 'http://' + url;
|
||||
}
|
||||
|
||||
// Add trailing slash if there is no path (just domain)
|
||||
// Check if URL has no slash after the domain
|
||||
const without_protocol = full_url.replace(/^https?:\/\//i, '');
|
||||
if (without_protocol.indexOf('/') === -1) {
|
||||
full_url += '/';
|
||||
}
|
||||
|
||||
return full_url;
|
||||
}
|
||||
|
||||
@@ -840,100 +840,22 @@ class Manifest
|
||||
|
||||
/**
|
||||
* Get all routes from the manifest
|
||||
*
|
||||
* Returns unified route structure: $routes[$pattern] => route_data
|
||||
* where route_data contains:
|
||||
* - methods: ['GET', 'POST']
|
||||
* - type: 'spa' | 'standard'
|
||||
* - class: Full class name
|
||||
* - method: Method name
|
||||
* - file: File path
|
||||
* - require: Auth requirements
|
||||
* - js_action_class: (SPA routes only) JavaScript action class
|
||||
*/
|
||||
public static function get_routes(): array
|
||||
{
|
||||
static::init();
|
||||
|
||||
$routes = [
|
||||
'controllers' => [],
|
||||
'api' => [],
|
||||
];
|
||||
|
||||
// Look for Route attributes - must check all namespaces since Route is not a real class
|
||||
// PHP attributes without an import will use the current namespace
|
||||
$files = static::get_all();
|
||||
$route_classes = [];
|
||||
|
||||
foreach ($files as $file => $metadata) {
|
||||
// Check public static method attributes for any attribute ending with 'Route'
|
||||
if (isset($metadata['public_static_methods'])) {
|
||||
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
|
||||
if (isset($method_data['attributes'])) {
|
||||
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
|
||||
// Check if this is a Route attribute (ends with \Route or is just Route)
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') {
|
||||
$route_classes[] = [
|
||||
'file' => $file,
|
||||
'class' => $metadata['class'] ?? null,
|
||||
'fqcn' => $metadata['fqcn'] ?? null,
|
||||
'method' => $method_name,
|
||||
'type' => 'method',
|
||||
'instances' => $attr_instances,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($route_classes as $item) {
|
||||
if ($item['type'] === 'method') {
|
||||
foreach ($item['instances'] as $route_args) {
|
||||
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
|
||||
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
|
||||
$name = $route_args[2] ?? ($route_args['name'] ?? null);
|
||||
|
||||
if ($pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($pattern[0] !== '/') {
|
||||
$pattern = '/' . $pattern;
|
||||
}
|
||||
|
||||
// Determine type (API or controller)
|
||||
$type = str_contains($item['file'], '/api/') || str_contains($item['class'] ?? '', 'Api') ? 'api' : 'controllers';
|
||||
|
||||
// Initialize route if not exists
|
||||
if (!isset($routes[$type][$pattern])) {
|
||||
$routes[$type][$pattern] = [];
|
||||
}
|
||||
|
||||
// Extract Auth attributes for this method from the file metadata
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$item['file']] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'];
|
||||
}
|
||||
|
||||
// Add for each HTTP method
|
||||
foreach ((array) $methods as $method) {
|
||||
$method_upper = strtoupper($method);
|
||||
|
||||
// Initialize method array if not exists
|
||||
if (!isset($routes[$type][$pattern][$method_upper])) {
|
||||
$routes[$type][$pattern][$method_upper] = [];
|
||||
}
|
||||
|
||||
// Add handler to array (allows duplicates for dispatch-time detection)
|
||||
$routes[$type][$pattern][$method_upper][] = [
|
||||
'class' => $item['fqcn'] ?? $item['class'],
|
||||
'method' => $item['method'],
|
||||
'name' => $name,
|
||||
'file' => $item['file'],
|
||||
'require' => $require_attrs,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
|
||||
ksort($routes['controllers']);
|
||||
ksort($routes['api']);
|
||||
|
||||
return $routes;
|
||||
return static::$data['data']['routes'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1115,8 +1037,8 @@ class Manifest
|
||||
if ($loaded_cache && env('APP_ENV') == 'production') {
|
||||
// In prod mode, if cache loaded, assume the cache is good, we are done with it
|
||||
console_debug('MANIFEST', 'Manifest cache loaded successfully (production mode)');
|
||||
self::$_has_manifest_ready = true;
|
||||
\App\RSpade\Core\Autoloader::register();
|
||||
|
||||
self::post_init();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1125,8 +1047,8 @@ class Manifest
|
||||
console_debug('MANIFEST', 'Manifest cache loaded successfully (development mode), validating...');
|
||||
if (self::__validate_cached_data()) {
|
||||
console_debug('MANIFEST', 'Manifest is valid');
|
||||
self::$_has_manifest_ready = true;
|
||||
\App\RSpade\Core\Autoloader::register();
|
||||
|
||||
self::post_init();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1183,8 +1105,37 @@ class Manifest
|
||||
RsxLocks::release_lock(self::$_manifest_compile_lock);
|
||||
console_debug('MANIFEST', 'Released manifest build lock');
|
||||
|
||||
self::post_init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-initialization hook called after manifest is fully loaded
|
||||
*
|
||||
* Called at the end of init() after manifest data is loaded (either from cache
|
||||
* or after scanning/rebuilding). At this point, the manifest is complete and
|
||||
* ready for queries.
|
||||
*
|
||||
* Current responsibilities:
|
||||
* - Sets $_has_manifest_ready flag to indicate manifest is available
|
||||
* - Registers the autoloader (which depends on manifest data)
|
||||
* - Loads classless PHP files (helpers, constants, procedural code)
|
||||
*
|
||||
* This is the appropriate place to perform operations that require the complete
|
||||
* manifest to be available, such as loading non-class PHP files that were
|
||||
* indexed during the scan.
|
||||
*/
|
||||
public static function post_init() {
|
||||
self::$_has_manifest_ready = true;
|
||||
\App\RSpade\Core\Autoloader::register();
|
||||
|
||||
// Load classless PHP files (helper functions, constants, etc.)
|
||||
$classless_files = self::$data['data']['classless_php_files'] ?? [];
|
||||
foreach ($classless_files as $file_path) {
|
||||
$full_path = base_path($file_path);
|
||||
if (file_exists($full_path)) {
|
||||
include_once $full_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1310,6 +1261,7 @@ class Manifest
|
||||
'data' => [
|
||||
'files' => $existing_files,
|
||||
'autoloader_class_map' => [],
|
||||
'routes' => [],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1467,6 +1419,9 @@ class Manifest
|
||||
// Build event handler index from attributes
|
||||
static::__build_event_handler_index();
|
||||
|
||||
// Build classless PHP files index
|
||||
static::__build_classless_php_files_index();
|
||||
|
||||
// =======================================================
|
||||
// Phase 5: Process Modules - Run manifest support modules and build autoloader
|
||||
// =======================================================
|
||||
@@ -1903,6 +1858,36 @@ class Manifest
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build index of classless PHP files
|
||||
*
|
||||
* Creates a simple array of file paths for all PHP files in the manifest
|
||||
* that do not contain a class. These files typically contain helper functions,
|
||||
* constants, or other procedural code that needs to be loaded during post_init().
|
||||
*
|
||||
* Stored in manifest data at: $data['data']['classless_php_files']
|
||||
*/
|
||||
protected static function __build_classless_php_files_index()
|
||||
{
|
||||
static::$data['data']['classless_php_files'] = [];
|
||||
|
||||
// Scan all PHP files
|
||||
foreach (static::$data['data']['files'] as $file_path => $metadata) {
|
||||
// Only process PHP files
|
||||
if (($metadata['extension'] ?? '') !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files that have a class
|
||||
if (!empty($metadata['class'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add file path to classless index
|
||||
static::$data['data']['classless_php_files'][] = $file_path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load changed PHP files and their dependencies
|
||||
*
|
||||
@@ -3167,15 +3152,21 @@ class Manifest
|
||||
|
||||
// Check for conflicting attributes
|
||||
$conflicts = [];
|
||||
if ($has_route) $conflicts[] = 'Route';
|
||||
if ($has_ajax_endpoint) $conflicts[] = 'Ajax_Endpoint';
|
||||
if ($has_task) $conflicts[] = 'Task';
|
||||
if ($has_route) {
|
||||
$conflicts[] = 'Route';
|
||||
}
|
||||
if ($has_ajax_endpoint) {
|
||||
$conflicts[] = 'Ajax_Endpoint';
|
||||
}
|
||||
if ($has_task) {
|
||||
$conflicts[] = 'Task';
|
||||
}
|
||||
|
||||
if (count($conflicts) > 1) {
|
||||
$class_name = $metadata['class'] ?? 'Unknown';
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Method cannot have multiple execution type attributes: " . implode(', ', $conflicts) . "\n" .
|
||||
'Method cannot have multiple execution type attributes: ' . implode(', ', $conflicts) . "\n" .
|
||||
"Class: {$class_name}\n" .
|
||||
"Method: {$method_name}\n" .
|
||||
"File: {$file_path}\n" .
|
||||
|
||||
@@ -18,7 +18,7 @@ class Filename_Suggester
|
||||
* @param string $class_name Class name
|
||||
* @param string $extension File extension (php, js)
|
||||
* @param bool $is_rspade True if in app/RSpade/, false if in rsx/
|
||||
* @param bool $is_jqhtml_component True if JS class extends Jqhtml_Component
|
||||
* @param bool $is_jqhtml_component True if JS class extends Component
|
||||
* @return string Suggested filename
|
||||
*/
|
||||
public static function get_suggested_class_filename(
|
||||
|
||||
@@ -86,6 +86,16 @@ class Rsx_Framework_Provider extends ServiceProvider
|
||||
config(['rsx' => $merged_config]);
|
||||
}
|
||||
|
||||
// Merge additional config from RSX_ADDITIONAL_CONFIG env variable
|
||||
// Used by test runner to include test directory in manifest
|
||||
$additional_config_path = env('RSX_ADDITIONAL_CONFIG');
|
||||
if ($additional_config_path && file_exists($additional_config_path)) {
|
||||
$additional_config = require $additional_config_path;
|
||||
$current_config = config('rsx', []);
|
||||
$merged_config = array_merge_deep($current_config, $additional_config);
|
||||
config(['rsx' => $merged_config]);
|
||||
}
|
||||
|
||||
// Remove .claude/CLAUDE.md symlink if in framework developer mode
|
||||
// This allows dual CLAUDE.md files: one for framework dev, one for distribution
|
||||
// Only runs in specific development environment to avoid affecting other setups
|
||||
@@ -291,6 +301,9 @@ class Rsx_Framework_Provider extends ServiceProvider
|
||||
// Register RSX view namespace for path-agnostic views
|
||||
$this->app['view']->addNamespace('rsx', base_path('rsx'));
|
||||
|
||||
// Register RSpade framework view namespace
|
||||
$this->app['view']->addNamespace('rspade', base_path('app/RSpade'));
|
||||
|
||||
// Register RSX route macro on Route facade
|
||||
if (!Route::hasMacro('rsx')) {
|
||||
Route::macro('rsx', function ($target, $params = []) {
|
||||
|
||||
@@ -9,12 +9,10 @@
|
||||
|
||||
namespace App\RSpade\Core;
|
||||
|
||||
use App\Models\FlashAlert;
|
||||
use RuntimeException;
|
||||
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
|
||||
use App\RSpade\Core\Events\Event_Registry;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Session\Session;
|
||||
|
||||
/**
|
||||
* Core RSX framework utility class
|
||||
@@ -42,14 +40,21 @@ class Rsx
|
||||
*/
|
||||
protected static $current_params = null;
|
||||
|
||||
/**
|
||||
* Current route type ('spa' or 'standard')
|
||||
* @var string|null
|
||||
*/
|
||||
protected static $current_route_type = null;
|
||||
|
||||
/**
|
||||
* Set the current controller and action being executed
|
||||
*
|
||||
* @param string $controller_class The controller class name
|
||||
* @param string $action_method The action method name
|
||||
* @param array $params Optional request params to store
|
||||
* @param string|null $route_type Route type ('spa' or 'standard')
|
||||
*/
|
||||
public static function _set_current_controller_action($controller_class, $action_method, array $params = [])
|
||||
public static function _set_current_controller_action($controller_class, $action_method, array $params = [], $route_type = null)
|
||||
{
|
||||
// Extract just the class name without namespace
|
||||
$parts = explode('\\', $controller_class);
|
||||
@@ -58,6 +63,7 @@ class Rsx
|
||||
static::$current_controller = $class_name;
|
||||
static::$current_action = $action_method;
|
||||
static::$current_params = $params;
|
||||
static::$current_route_type = $route_type;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +96,16 @@ class Rsx
|
||||
return static::$current_params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current route is a SPA route
|
||||
*
|
||||
* @return bool True if current route type is 'spa', false otherwise
|
||||
*/
|
||||
public static function is_spa()
|
||||
{
|
||||
return static::$current_route_type === 'spa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current controller and action tracking
|
||||
*/
|
||||
@@ -98,117 +114,17 @@ class Rsx
|
||||
static::$current_controller = null;
|
||||
static::$current_action = null;
|
||||
static::$current_params = null;
|
||||
static::$current_route_type = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a flash alert message for the current session
|
||||
*
|
||||
* @param string $message The message to display
|
||||
* @param string $class_attribute Optional CSS class attribute (defaults to 'alert alert-danger alert-flash')
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_alert($message, $class_attribute = 'alert alert-danger alert-flash')
|
||||
{
|
||||
$session_id = Session::get_session_id();
|
||||
if ($session_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flash_alert = new FlashAlert();
|
||||
$flash_alert->session_id = $session_id;
|
||||
$flash_alert->message = $message;
|
||||
$flash_alert->class_attribute = $class_attribute;
|
||||
$flash_alert->created_at = now();
|
||||
$flash_alert->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all flash alerts for the current session
|
||||
*
|
||||
* Returns HTML for all flash messages and deletes them from the database.
|
||||
* Messages are rendered as Bootstrap 5 alerts with dismissible buttons.
|
||||
*
|
||||
* @return string HTML string containing all flash alerts or empty string
|
||||
*/
|
||||
public static function render_flash_alerts()
|
||||
{
|
||||
$session_id = Session::get_session_id();
|
||||
if ($session_id === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get all flash alerts for this session
|
||||
$alerts = FlashAlert::where('session_id', $session_id)
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
|
||||
if ($alerts->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Delete the alerts now that we're rendering them
|
||||
FlashAlert::where('session_id', $session_id)
|
||||
->delete();
|
||||
|
||||
// Build HTML for all alerts
|
||||
$html = '';
|
||||
foreach ($alerts as $alert) {
|
||||
$message = htmlspecialchars($alert->message);
|
||||
$class = htmlspecialchars($alert->class_attribute);
|
||||
|
||||
$html .= <<<HTML
|
||||
<div class="{$class} show" role="alert">
|
||||
{$message}
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add a success flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_success($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-success alert-flash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add an error flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_error($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-danger alert-flash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add a warning flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_warning($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-warning alert-flash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to add an info flash alert
|
||||
*
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
public static function flash_info($message)
|
||||
{
|
||||
self::flash_alert($message, 'alert alert-info alert-flash');
|
||||
}
|
||||
// Flash alert methods have been removed - use Flash class instead:
|
||||
// Flash_Alert::success($message)
|
||||
// Flash_Alert::error($message)
|
||||
// Flash_Alert::info($message)
|
||||
// Flash_Alert::warning($message)
|
||||
//
|
||||
// See: /system/app/RSpade/Core/Flash/Flash.php
|
||||
// See: /system/app/RSpade/Core/Flash/CLAUDE.md
|
||||
|
||||
/**
|
||||
* Generate URL for a controller route
|
||||
@@ -223,42 +139,54 @@ HTML;
|
||||
*
|
||||
* Usage examples:
|
||||
* ```php
|
||||
* // Simple route without parameters (defaults to 'index' action)
|
||||
* // Controller route (defaults to 'index' method)
|
||||
* $url = Rsx::Route('Frontend_Index_Controller');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with explicit action
|
||||
* $url = Rsx::Route('Frontend_Index_Controller', 'index');
|
||||
* // Returns: /dashboard
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', 123);
|
||||
* // Controller route with explicit method
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller::view', 123);
|
||||
* // Returns: /clients/view/123
|
||||
*
|
||||
* // SPA action route
|
||||
* $url = Rsx::Route('Contacts_Index_Action');
|
||||
* // Returns: /contacts
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* $url = Rsx::Route('Contacts_View_Action', 123);
|
||||
* // Returns: /contacts/123
|
||||
*
|
||||
* // Route with named parameters (array)
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', ['id' => 'C001']);
|
||||
* // Returns: /clients/view/C001
|
||||
* $url = Rsx::Route('Contacts_View_Action', ['id' => 'C001']);
|
||||
* // Returns: /contacts/C001
|
||||
*
|
||||
* // Route with required and query parameters
|
||||
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', [
|
||||
* $url = Rsx::Route('Contacts_View_Action', [
|
||||
* 'id' => 'C001',
|
||||
* 'tab' => 'history'
|
||||
* ]);
|
||||
* // Returns: /clients/view/C001?tab=history
|
||||
* // Returns: /contacts/C001?tab=history
|
||||
*
|
||||
* // Placeholder route for scaffolding (controller doesn't need to exist)
|
||||
* $url = Rsx::Route('Future_Feature_Controller', '#index');
|
||||
* // Placeholder route for scaffolding (doesn't need to exist)
|
||||
* $url = Rsx::Route('Future_Feature_Controller::#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param string $class_name The controller class name (e.g., 'User_Controller')
|
||||
* @param string $action_name The action/method name (defaults to 'index'). Use '#action' for placeholders.
|
||||
* @param string $action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
|
||||
* @param int|array|\stdClass|null $params Route parameters. Integer sets 'id', array/object provides named params.
|
||||
* @return string The generated URL
|
||||
* @throws RuntimeException If class doesn't exist, isn't a controller, method doesn't exist, or lacks Route attribute
|
||||
* @throws RuntimeException If class doesn't exist, isn't a controller/action, method doesn't exist, or lacks Route attribute
|
||||
*/
|
||||
public static function Route($class_name, $action_name = 'index', $params = null)
|
||||
public static function Route($action, $params = null)
|
||||
{
|
||||
// Parse action into class_name and action_name
|
||||
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
|
||||
if (str_contains($action, '::')) {
|
||||
[$class_name, $action_name] = explode('::', $action, 2);
|
||||
} else {
|
||||
$class_name = $action;
|
||||
$action_name = 'index';
|
||||
}
|
||||
|
||||
// Normalize params to array
|
||||
$params_array = [];
|
||||
if (is_int($params)) {
|
||||
@@ -281,8 +209,8 @@ HTML;
|
||||
try {
|
||||
$metadata = Manifest::php_get_metadata_by_class($class_name);
|
||||
} catch (RuntimeException $e) {
|
||||
// Report error at caller's location (the blade template or PHP code calling Rsx::Route)
|
||||
throw new Rsx_Caller_Exception("Could not generate route URL: controller class {$class_name} not found");
|
||||
// Not found as PHP class - might be a SPA action, try that instead
|
||||
return static::_try_spa_action_route($class_name, $params_array);
|
||||
}
|
||||
|
||||
// Verify it extends Rsx_Controller_Abstract
|
||||
@@ -366,7 +294,8 @@ HTML;
|
||||
}
|
||||
|
||||
if (!$has_route) {
|
||||
throw new Rsx_Caller_Exception("Method {$class_name}::{$action_name} must have Route or Ajax_Endpoint attribute");
|
||||
// Not a controller method with Route/Ajax - check if it's a SPA action class
|
||||
return static::_try_spa_action_route($class_name, $params_array);
|
||||
}
|
||||
|
||||
if (!$route_pattern) {
|
||||
@@ -377,6 +306,68 @@ HTML;
|
||||
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, $action_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to generate URL for a SPA action class
|
||||
* Called when class lookup fails for controller - checks if it's a JavaScript SPA action
|
||||
*
|
||||
* @param string $class_name The class name (might be a JS SPA action)
|
||||
* @param array $params_array Parameters for URL generation
|
||||
* @return string The generated URL
|
||||
* @throws Rsx_Caller_Exception If not a valid SPA action or route not found
|
||||
*/
|
||||
protected static function _try_spa_action_route(string $class_name, array $params_array): string
|
||||
{
|
||||
// Check if this is a JavaScript class that extends Spa_Action
|
||||
try {
|
||||
$is_spa_action = Manifest::js_is_subclass_of($class_name, 'Spa_Action');
|
||||
} catch (\RuntimeException $e) {
|
||||
// Not a JS class or not found
|
||||
throw new Rsx_Caller_Exception("Class {$class_name} must extend Rsx_Controller_Abstract or Spa_Action");
|
||||
}
|
||||
|
||||
if (!$is_spa_action) {
|
||||
throw new Rsx_Caller_Exception("JavaScript class {$class_name} must extend Spa_Action to generate routes");
|
||||
}
|
||||
|
||||
// Get the file path for this JS class
|
||||
try {
|
||||
$file_path = Manifest::js_find_class($class_name);
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new Rsx_Caller_Exception("SPA action class {$class_name} not found in manifest");
|
||||
}
|
||||
|
||||
// Get file metadata which contains decorator information
|
||||
try {
|
||||
$file_data = Manifest::get_file($file_path);
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new Rsx_Caller_Exception("File metadata not found for SPA action {$class_name}");
|
||||
}
|
||||
|
||||
// Extract route pattern from decorators
|
||||
// JavaScript files have 'decorators' array in their metadata
|
||||
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
|
||||
$route_pattern = null;
|
||||
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
|
||||
foreach ($file_data['decorators'] as $decorator) {
|
||||
// Decorator format: [0 => 'decorator_name', 1 => [arguments]]
|
||||
if (isset($decorator[0]) && $decorator[0] === 'route') {
|
||||
// First argument is the route pattern
|
||||
if (isset($decorator[1][0])) {
|
||||
$route_pattern = $decorator[1][0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$route_pattern) {
|
||||
throw new Rsx_Caller_Exception("SPA action {$class_name} must have @route() decorator with pattern");
|
||||
}
|
||||
|
||||
// Generate URL from pattern using same logic as regular routes
|
||||
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, '(SPA action)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from route pattern by replacing parameters
|
||||
*
|
||||
|
||||
118
app/RSpade/Core/SPA/CLAUDE.md
Executable file
118
app/RSpade/Core/SPA/CLAUDE.md
Executable file
@@ -0,0 +1,118 @@
|
||||
# RSpade Spa System
|
||||
|
||||
## Overview
|
||||
|
||||
The RSpade Spa system enables client-side routing for authenticated areas of applications using the JQHTML component framework.
|
||||
|
||||
## Core Classes
|
||||
|
||||
### Spa_Action
|
||||
Base class for Spa pages/routes. Each action represents a distinct page in the application.
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
class Users_View_Action extends Spa_Action {
|
||||
static route = '/users/:user_id';
|
||||
static layout = 'Frontend_Layout';
|
||||
|
||||
on_create() {
|
||||
console.log(this.args.user_id); // URL parameter from route
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spa_Layout
|
||||
Persistent wrapper component containing navigation, header, footer, etc. Layouts have a content area where actions render.
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
class Frontend_Layout extends Spa_Layout {
|
||||
on_create() {
|
||||
// Initialize layout structure
|
||||
// Create navigation, header, footer
|
||||
// Define content area for actions
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spa
|
||||
Main Spa application class that initializes the router and manages navigation between actions.
|
||||
|
||||
### Spa_Router
|
||||
Core routing engine adapted from JQHTML framework. Handles URL matching, parameter extraction, and navigation.
|
||||
|
||||
## Discovery
|
||||
|
||||
Spa actions are discovered during manifest build using:
|
||||
```php
|
||||
Manifest::is_js_subclass_of('Spa_Action')
|
||||
```
|
||||
|
||||
No filename conventions required - any class extending `Spa_Action` is automatically registered.
|
||||
|
||||
## URL Generation
|
||||
|
||||
**PHP:**
|
||||
```php
|
||||
$url = Rsx::SpaRoute('Users_View_Action', ['user_id' => 123]);
|
||||
// Returns: "/users/123"
|
||||
```
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
const url = Rsx.SpaRoute('Users_View_Action', {user_id: 123, tab: 'posts'});
|
||||
// Returns: "/users/123?tab=posts"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Bootstrap Flow
|
||||
|
||||
1. User requests Spa route (e.g., `/users/123`)
|
||||
2. Dispatcher detects Spa route in manifest
|
||||
3. Server renders `spa_bootstrap.blade.php` with bundle
|
||||
4. Client initializes Spa application
|
||||
5. Router matches URL to action
|
||||
6. Layout renders with action inside
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
1. User clicks link or calls `Rsx.SpaRoute()`
|
||||
2. Router matches URL to action class
|
||||
3. Current action destroyed (if exists)
|
||||
4. New action instantiated with URL parameters
|
||||
5. Action renders within persistent layout
|
||||
6. Browser history updated
|
||||
|
||||
## Implementation Status
|
||||
|
||||
**Phase 1: Foundation Setup** - ✅ COMPLETE
|
||||
- Directory structure created
|
||||
- Placeholder files in place
|
||||
- Router code copied from JQHTML export
|
||||
|
||||
**Phase 2+:** See `/var/www/html/docs.dev/Spa_INTEGRATION_PLAN.md`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
/system/app/RSpade/Core/Jqhtml_Spa/
|
||||
├── CLAUDE.md # This file
|
||||
├── Spa_Bootstrap.php # Server-side bootstrap handler
|
||||
├── Spa_Parser.php # Parse JS files for action metadata
|
||||
├── Spa_Manifest.php # Manifest integration helper
|
||||
├── spa_bootstrap.blade.php # Bootstrap blade layout
|
||||
├── Spa_Router.js # Core router (from JQHTML)
|
||||
├── Spa.js # Spa application base class
|
||||
├── Spa_Layout.js # Layout base class
|
||||
└── Spa_Action.js # Action base class
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Spa routes are auth-only (no SEO concerns)
|
||||
- Server does not render Spa pages (client-side only)
|
||||
- Actions live alongside PHP controllers in feature directories
|
||||
- Controllers provide Ajax endpoints, actions provide UI
|
||||
- Layouts persist across action navigation (no re-render)
|
||||
- Component renamed from `Component` to `Component` (legacy alias maintained)
|
||||
640
app/RSpade/Core/SPA/Spa.js
Executable file
640
app/RSpade/Core/SPA/Spa.js
Executable file
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* Spa - Single Page Application orchestrator for RSpade
|
||||
*
|
||||
* This class manages the Spa lifecycle:
|
||||
* - Auto-discovers action classes extending Spa_Action
|
||||
* - Extracts route information from decorator metadata
|
||||
* - Registers routes with the router
|
||||
* - Dispatches to the current URL
|
||||
*
|
||||
* Initialization happens automatically during the on_app_init phase when
|
||||
* window.rsxapp.is_spa === true.
|
||||
*
|
||||
* Unlike JQHTML, this is a static class (not a Component) following the RS3 pattern.
|
||||
*/
|
||||
class Spa {
|
||||
// Registered routes: { pattern: action_class }
|
||||
static routes = {};
|
||||
|
||||
// Current layout instance
|
||||
static layout = null;
|
||||
|
||||
// Current action instance
|
||||
static action = null;
|
||||
|
||||
// Current route instance
|
||||
static route = null;
|
||||
|
||||
// Current route 'params'
|
||||
static params = null;
|
||||
|
||||
// Flag to prevent re-entrant dispatch
|
||||
static is_dispatching = false;
|
||||
|
||||
/**
|
||||
* Framework module initialization hook called during framework boot
|
||||
* Only runs when window.rsxapp.is_spa === true
|
||||
*/
|
||||
static _on_framework_modules_init() {
|
||||
// Only initialize Spa if we're in a Spa route
|
||||
if (!window.rsxapp || !window.rsxapp.is_spa) {
|
||||
return;
|
||||
}
|
||||
|
||||
console_debug('Spa', 'Initializing Spa system');
|
||||
|
||||
// Discover and register all action classes
|
||||
Spa.discover_actions();
|
||||
|
||||
// Setup browser integration using History API
|
||||
// Note: Navigation API evaluated but not mature enough for production use
|
||||
// See: /docs.dev/SPA_BROWSER_INTEGRATION.md for details
|
||||
console.log('[Spa] Using History API for browser integration');
|
||||
Spa.setup_browser_integration();
|
||||
|
||||
// Dispatch to current URL (including hash for initial load)
|
||||
const initial_url = window.location.pathname + window.location.search + window.location.hash;
|
||||
console_debug('Spa', 'Dispatching to initial URL: ' + initial_url);
|
||||
Spa.dispatch(initial_url, { history: 'none' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all classes extending Spa_Action and register their routes
|
||||
*/
|
||||
static discover_actions() {
|
||||
const all_classes = Manifest.get_all_classes();
|
||||
let action_count = 0;
|
||||
|
||||
for (const class_info of all_classes) {
|
||||
const class_object = class_info.class_object;
|
||||
const class_name = class_info.class_name;
|
||||
|
||||
// Check if this class extends Spa_Action
|
||||
if (class_object.prototype instanceof Spa_Action || class_object === Spa_Action) {
|
||||
// Skip the base class itself
|
||||
if (class_object === Spa_Action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract route patterns from decorator metadata
|
||||
const routes = class_object._spa_routes || [];
|
||||
|
||||
if (routes.length === 0) {
|
||||
console.warn(`Spa: Action ${class_name} has no routes defined`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register each route pattern
|
||||
for (const pattern of routes) {
|
||||
Spa.register_route(pattern, class_object);
|
||||
console_debug('Spa', `Registered route: ${pattern} → ${class_name}`);
|
||||
}
|
||||
|
||||
action_count++;
|
||||
}
|
||||
}
|
||||
|
||||
console_debug('Spa', `Discovered ${action_count} action classes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a route pattern to an action class
|
||||
*/
|
||||
static register_route(pattern, action_class) {
|
||||
// Normalize pattern - remove trailing /index
|
||||
if (pattern.endsWith('/index')) {
|
||||
pattern = pattern.slice(0, -6) || '/';
|
||||
}
|
||||
|
||||
// Check for duplicates in dev mode
|
||||
if (Rsx.is_dev() && Spa.routes[pattern]) {
|
||||
console.error(`Spa: Duplicate route '${pattern}' - ${action_class.name} conflicts with ${Spa.routes[pattern].name}`);
|
||||
}
|
||||
|
||||
Spa.routes[pattern] = action_class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match URL to a route and extract parameters
|
||||
* Returns: { action_class, args, layout } or null
|
||||
*/
|
||||
static match_url_to_route(url) {
|
||||
// Parse URL to get path and query params
|
||||
const parsed = Spa.parse_url(url);
|
||||
let path = parsed.path;
|
||||
|
||||
// Normalize path - remove leading/trailing slashes for matching
|
||||
path = path.substring(1); // Remove leading /
|
||||
|
||||
// Remove /index suffix
|
||||
if (path === 'index' || path.endsWith('/index')) {
|
||||
path = path.slice(0, -5) || '';
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
const exact_pattern = '/' + path;
|
||||
if (Spa.routes[exact_pattern]) {
|
||||
return {
|
||||
action_class: Spa.routes[exact_pattern],
|
||||
args: parsed.query_params,
|
||||
layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout',
|
||||
};
|
||||
}
|
||||
|
||||
// Try pattern matching with :param segments
|
||||
for (const pattern in Spa.routes) {
|
||||
const match = Spa.match_pattern(path, pattern);
|
||||
if (match) {
|
||||
// Merge parameters with correct priority order:
|
||||
// 1. GET parameters (from query string, lowest priority)
|
||||
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
|
||||
// This matches the PHP Dispatcher behavior where route params override GET params
|
||||
const args = { ...parsed.query_params, ...match };
|
||||
|
||||
return {
|
||||
action_class: Spa.routes[pattern],
|
||||
args: args,
|
||||
layout: Spa.routes[pattern]._spa_layout || 'Default_Layout',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No match found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path against a pattern with :param segments
|
||||
* Returns object with extracted params or null if no match
|
||||
*/
|
||||
static match_pattern(path, pattern) {
|
||||
// Remove leading / from both
|
||||
path = path.replace(/^\//, '');
|
||||
pattern = pattern.replace(/^\//, '');
|
||||
|
||||
// Split into segments
|
||||
const path_segments = path.split('/');
|
||||
const pattern_segments = pattern.split('/');
|
||||
|
||||
// Must have same number of segments
|
||||
if (path_segments.length !== pattern_segments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = {};
|
||||
|
||||
for (let i = 0; i < pattern_segments.length; i++) {
|
||||
const pattern_seg = pattern_segments[i];
|
||||
const path_seg = path_segments[i];
|
||||
|
||||
if (pattern_seg.startsWith(':')) {
|
||||
// This is a parameter - extract it
|
||||
const param_name = pattern_seg.substring(1);
|
||||
params[param_name] = decodeURIComponent(path_seg);
|
||||
} else {
|
||||
// This is a literal - must match exactly
|
||||
if (pattern_seg !== path_seg) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL into components
|
||||
*/
|
||||
static parse_url(url) {
|
||||
let parsed_url;
|
||||
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
parsed_url = new URL(url);
|
||||
} else {
|
||||
parsed_url = new URL(url, window.location.href);
|
||||
}
|
||||
} catch (e) {
|
||||
parsed_url = new URL(window.location.href);
|
||||
}
|
||||
|
||||
const path = parsed_url.pathname;
|
||||
const search = parsed_url.search;
|
||||
|
||||
// Parse query string
|
||||
const query_params = {};
|
||||
if (search && search !== '?') {
|
||||
const query = search.startsWith('?') ? search.substring(1) : search;
|
||||
const pairs = query.split('&');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
query_params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { path, search, query_params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from pattern and parameters
|
||||
*/
|
||||
static generate_url_from_pattern(pattern, params = {}) {
|
||||
let url = pattern;
|
||||
const used_params = new Set();
|
||||
|
||||
// Replace :param placeholders
|
||||
url = url.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, param_name) => {
|
||||
if (params.hasOwnProperty(param_name)) {
|
||||
used_params.add(param_name);
|
||||
return encodeURIComponent(params[param_name]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Collect unused parameters for query string
|
||||
const query_params = {};
|
||||
for (const key in params) {
|
||||
if (!used_params.has(key)) {
|
||||
query_params[key] = params[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Add query string if needed
|
||||
if (Object.keys(query_params).length > 0) {
|
||||
const query_string = Object.entries(query_params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
url += '?' + query_string;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup browser integration for back/forward and link interception
|
||||
*
|
||||
* This implements Phase 1 of browser integration using the History API.
|
||||
* See: /docs.dev/SPA_BROWSER_INTEGRATION.md for complete documentation.
|
||||
*
|
||||
* Key Behaviors:
|
||||
* - Intercepts clicks on <a> tags for same-domain SPA routes
|
||||
* - Preserves standard browser behaviors (Ctrl+click, target="_blank", etc.)
|
||||
* - Handles back/forward navigation with scroll restoration
|
||||
* - Hash-only changes don't create history entries
|
||||
* - Defers to server for edge cases (external links, non-SPA routes, etc.)
|
||||
*/
|
||||
static setup_browser_integration() {
|
||||
console_debug('Spa', 'Setting up browser integration (History API mode)');
|
||||
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (e) => {
|
||||
console_debug('Spa', 'popstate event fired (back/forward navigation)');
|
||||
console.warn('[Spa.dispatch] Handling history popstate event', {
|
||||
url: window.location.pathname + window.location.search + window.location.hash,
|
||||
state: e.state
|
||||
});
|
||||
|
||||
// Get target URL (browser has already updated location)
|
||||
const url = window.location.pathname + window.location.search + window.location.hash;
|
||||
|
||||
// Retrieve scroll position from history state
|
||||
const scroll = e.state?.scroll || null;
|
||||
|
||||
// TODO: Form Data Restoration
|
||||
// Retrieve form data from history state and restore after action renders
|
||||
// Implementation notes:
|
||||
// - Get form_data from e.state?.form_data
|
||||
// - After action on_ready(), find all form inputs
|
||||
// - Restore values from form_data object
|
||||
// - Trigger change events for restored fields
|
||||
// - Handle edge cases:
|
||||
// * Dynamic forms (loaded via Ajax) - need to wait for form to exist
|
||||
// * File inputs (cannot be programmatically set for security)
|
||||
// * Custom components (need vals() method for restoration)
|
||||
// * Timing (must restore after form renders, possibly in on_ready)
|
||||
// const form_data = e.state?.form_data || {};
|
||||
|
||||
// Dispatch without modifying history (we're already at the target URL)
|
||||
Spa.dispatch(url, {
|
||||
history: 'none',
|
||||
scroll: scroll
|
||||
});
|
||||
});
|
||||
|
||||
// Intercept link clicks using event delegation
|
||||
document.addEventListener('click', (e) => {
|
||||
// Find <a> tag in event path (handles clicks on child elements)
|
||||
let link = e.target.closest('a');
|
||||
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Ignore if:
|
||||
// - No href
|
||||
// - Ctrl/Cmd/Meta is pressed (open in new tab)
|
||||
// - Has target attribute
|
||||
// - Not left click (button 0)
|
||||
// - Is empty or hash-only (#)
|
||||
if (!href ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
link.getAttribute('target') ||
|
||||
e.button !== 0 ||
|
||||
href === '' ||
|
||||
href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse URLs for comparison
|
||||
const current_parsed = Spa.parse_url(window.location.href);
|
||||
const target_parsed = Spa.parse_url(href);
|
||||
|
||||
// If same page (same path + search), let browser handle (causes reload)
|
||||
// This mimics non-SPA behavior where clicking current page refreshes
|
||||
if (current_parsed.path === target_parsed.path &&
|
||||
current_parsed.search === target_parsed.search) {
|
||||
console_debug('Spa', 'Same page click, letting browser reload');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept same-domain links
|
||||
if (current_parsed.host !== target_parsed.host) {
|
||||
console_debug('Spa', 'External domain link, letting browser handle: ' + href);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if target URL matches a Spa route
|
||||
if (Spa.match_url_to_route(href)) {
|
||||
console_debug('Spa', 'Intercepting link click: ' + href);
|
||||
e.preventDefault();
|
||||
Spa.dispatch(href, { history: 'auto' });
|
||||
} else {
|
||||
console_debug('Spa', 'No SPA route match, letting server handle: ' + href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main dispatch method - navigate to a URL
|
||||
*
|
||||
* This is the single entry point for all navigation within the SPA.
|
||||
* Handles route matching, history management, layout/action lifecycle, and scroll restoration.
|
||||
*
|
||||
* @param {string} url - Target URL (relative or absolute)
|
||||
* @param {object} options - Navigation options
|
||||
* @param {string} options.history - 'auto'|'push'|'replace'|'none' (default: 'auto')
|
||||
* - 'auto': Push for new URLs, replace for same URL
|
||||
* - 'push': Always create new history entry
|
||||
* - 'replace': Replace current history entry
|
||||
* - 'none': Don't modify history (used for back/forward)
|
||||
* @param {object|null} options.scroll - Scroll position {x, y} to restore (default: null = scroll to top)
|
||||
* @param {boolean} options.triggers - Fire before/after dispatch events (default: true)
|
||||
*/
|
||||
static async dispatch(url, options = {}) {
|
||||
if (Spa.is_dispatching) {
|
||||
console.warn('Spa: Already dispatching, ignoring nested dispatch');
|
||||
return;
|
||||
}
|
||||
|
||||
Spa.is_dispatching = true;
|
||||
|
||||
try {
|
||||
const opts = {
|
||||
history: options.history || 'auto',
|
||||
scroll: options.scroll || null,
|
||||
triggers: options.triggers !== false,
|
||||
};
|
||||
|
||||
console_debug('Spa', 'Dispatching to: ' + url + ' (history: ' + opts.history + ')');
|
||||
|
||||
// Handle fully qualified URLs
|
||||
const current_domain = window.location.hostname;
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
try {
|
||||
const parsed_url = new URL(url);
|
||||
|
||||
// Check if different domain
|
||||
if (parsed_url.hostname !== current_domain) {
|
||||
// External domain - navigate away
|
||||
console_debug('Spa', 'External domain, navigating: ' + url);
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (external domain)', {
|
||||
url: url,
|
||||
reason: 'External domain'
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Same domain - strip to relative URL
|
||||
url = parsed_url.pathname + parsed_url.search + parsed_url.hash;
|
||||
console_debug('Spa', 'Same domain, stripped to relative: ' + url);
|
||||
} catch (e) {
|
||||
console.error('Spa: Invalid URL format:', url);
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the (now relative) URL
|
||||
const parsed = Spa.parse_url(url);
|
||||
|
||||
// CRITICAL: Strip hash from URL before route matching
|
||||
// Hash represents page state (e.g., DataGrid page number), not routing state
|
||||
// Hash is preserved in browser URL bar but not used for route matching
|
||||
const url_without_hash = parsed.path + parsed.search;
|
||||
|
||||
console_debug('Spa', 'URL for route matching (hash stripped): ' + url_without_hash);
|
||||
|
||||
// Try to match URL to a route (without hash)
|
||||
const route_match = Spa.match_url_to_route(url_without_hash);
|
||||
|
||||
// Check if this is the same URL we're currently on (without hash)
|
||||
const current_url = window.location.pathname + window.location.search;
|
||||
const is_same_url = url_without_hash === current_url;
|
||||
|
||||
// Same URL navigation with history: 'auto' should reload via server
|
||||
// This mimics non-SPA behavior where clicking current page refreshes
|
||||
if (is_same_url && opts.history === 'auto') {
|
||||
console_debug('Spa', 'Same URL with auto history, letting browser reload');
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (same URL reload)', {
|
||||
url: url,
|
||||
reason: 'Same URL with auto history - mimics browser reload behavior'
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_same_url && !route_match) {
|
||||
// We're being asked to navigate to the current URL, but it doesn't match
|
||||
// any known route. This shouldn't happen - prevents infinite redirect loop.
|
||||
Spa.spa_unknown_route_fatal(parsed.path);
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// If no route match and we're not on this URL, let server handle it
|
||||
if (!route_match) {
|
||||
console_debug('Spa', 'No route matched, letting server handle: ' + url);
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (no route match)', {
|
||||
url: url,
|
||||
reason: 'URL does not match any registered SPA routes'
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console_debug('Spa', 'Route match:', {
|
||||
action_class: route_match?.action_class?.name,
|
||||
args: route_match?.args,
|
||||
layout: route_match?.layout,
|
||||
});
|
||||
|
||||
// Check if action's @spa() attribute matches current SPA bootstrap
|
||||
const action_spa_controller = route_match.action_class._spa_controller_method;
|
||||
const current_spa_controller = window.rsxapp.current_controller + '::' + window.rsxapp.current_action;
|
||||
|
||||
if (action_spa_controller && action_spa_controller !== current_spa_controller) {
|
||||
// Different SPA module - let server bootstrap it
|
||||
console_debug('Spa', 'Different SPA module, letting server handle: ' + url);
|
||||
console_debug('Spa', ` Action uses: ${action_spa_controller}`);
|
||||
console_debug('Spa', ` Current SPA: ${current_spa_controller}`);
|
||||
console.warn('[Spa.dispatch] Executing document.location.href (different SPA module)', {
|
||||
url: url,
|
||||
reason: 'Action belongs to different SPA module/bundle',
|
||||
action_spa_controller: action_spa_controller,
|
||||
current_spa_controller: current_spa_controller
|
||||
});
|
||||
document.location.href = url;
|
||||
Spa.is_dispatching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update browser history with scroll position storage
|
||||
if (opts.history !== 'none') {
|
||||
// Store current scroll position before navigation
|
||||
const current_scroll = {
|
||||
x: window.scrollX || window.pageXOffset,
|
||||
y: window.scrollY || window.pageYOffset
|
||||
};
|
||||
|
||||
// Build history state object
|
||||
const state = {
|
||||
scroll: current_scroll,
|
||||
form_data: {} // Reserved for future form state restoration
|
||||
};
|
||||
|
||||
// Construct full URL with hash (hash is preserved in browser URL bar)
|
||||
const new_url = parsed.path + parsed.search + (parsed.hash || '');
|
||||
|
||||
if (opts.history === 'push' || (opts.history === 'auto' && !is_same_url)) {
|
||||
console_debug('Spa', 'Pushing history state');
|
||||
history.pushState(state, '', new_url);
|
||||
} else if (opts.history === 'replace' || (opts.history === 'auto' && is_same_url)) {
|
||||
console_debug('Spa', 'Replacing history state');
|
||||
history.replaceState(state, '', new_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Set global Spa state
|
||||
Spa.route = route_match;
|
||||
Spa.path = parsed.path;
|
||||
Spa.params = route_match.args;
|
||||
|
||||
// Get layout name and action info
|
||||
const layout_name = route_match.layout;
|
||||
const action_class = route_match.action_class;
|
||||
const action_name = action_class.name;
|
||||
|
||||
// Log successful SPA navigation
|
||||
console.warn('[Spa.dispatch] Executing SPA navigation', {
|
||||
url: url,
|
||||
path: parsed.path,
|
||||
params: route_match.args,
|
||||
action: action_name,
|
||||
layout: layout_name,
|
||||
history_mode: opts.history
|
||||
});
|
||||
|
||||
// Check if we need a new layout
|
||||
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
|
||||
// Stop old layout if exists (auto-stops children)
|
||||
if (Spa.layout) {
|
||||
await Spa.layout.trigger('unload');
|
||||
Spa.layout.stop();
|
||||
}
|
||||
|
||||
// Clear body and create new layout
|
||||
$('body').empty();
|
||||
$('body').attr('class', '');
|
||||
|
||||
// Create layout using component system
|
||||
Spa.layout = $('body').component(layout_name, {}).component();
|
||||
|
||||
// Wait for layout to be ready
|
||||
await Spa.layout.ready();
|
||||
|
||||
console_debug('Spa', `Created layout: ${layout_name}`);
|
||||
} else {
|
||||
// Wait for layout to finish previous action if still loading
|
||||
await Spa.layout.ready();
|
||||
}
|
||||
|
||||
// Tell layout to run the action
|
||||
Spa.layout._set_action(action_name, route_match.args, url);
|
||||
await Spa.layout._run_action();
|
||||
|
||||
// Scroll Restoration #1: Immediate (after action starts)
|
||||
// This occurs synchronously after the action component is created
|
||||
// May fail if page height is insufficient - that's okay, we'll retry later
|
||||
if (opts.scroll) {
|
||||
console_debug('Spa', 'Restoring scroll position (immediate): ' + opts.scroll.x + ', ' + opts.scroll.y);
|
||||
window.scrollTo(opts.scroll.x, opts.scroll.y);
|
||||
} else if (opts.scroll === undefined) {
|
||||
// Default: scroll to top for new navigation (only if scroll not explicitly set)
|
||||
console_debug('Spa', 'Scrolling to top (new navigation)');
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
// If opts.scroll === null, don't scroll (let Navigation API or browser handle it)
|
||||
|
||||
// TODO: Scroll Restoration #2 - After action on_ready() completes
|
||||
// This requires an action lifecycle event system to detect when on_ready() finishes.
|
||||
// Implementation notes:
|
||||
// - Listen for action on_ready completion event
|
||||
// - Only retry scroll if restoration #1 failed (page wasn't tall enough)
|
||||
// - Check if page height has increased since first attempt
|
||||
// - Avoid infinite retry loops (track attempts)
|
||||
// - This ensures scroll restoration works even when content loads asynchronously
|
||||
//
|
||||
// Additional context: The action may load data in on_load() that increases page height,
|
||||
// making the target scroll position accessible. The first restoration happens before
|
||||
// this content renders, so we need a second attempt after the page is fully ready.
|
||||
|
||||
console_debug('Spa', `Rendered ${action_name} in ${layout_name}`);
|
||||
} catch (error) {
|
||||
console.error('[Spa] Dispatch error:', error);
|
||||
// TODO: Better error handling - show error UI to user
|
||||
throw error;
|
||||
} finally {
|
||||
Spa.is_dispatching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fatal error when trying to navigate to unknown route on current URL
|
||||
* This shouldn't happen - prevents infinite redirect loops
|
||||
*/
|
||||
static spa_unknown_route_fatal(path) {
|
||||
console.error(`Unknown route for path ${path} - this shouldn't happen`);
|
||||
}
|
||||
}
|
||||
81
app/RSpade/Core/SPA/Spa_Action.js
Executable file
81
app/RSpade/Core/SPA/Spa_Action.js
Executable file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Spa_Action - Base class for Spa action components
|
||||
*
|
||||
* An Action represents a page/route in the Spa. Each action class defines:
|
||||
* - Route pattern(s) via @route() decorator
|
||||
* - Layout to render within via @layout() decorator
|
||||
* - Associated PHP controller via @spa() decorator
|
||||
*
|
||||
* Actions receive URL parameters in this.args and load data in on_load().
|
||||
*
|
||||
* Example:
|
||||
* @route('/contacts')
|
||||
* @layout('Frontend_Layout')
|
||||
* @spa('Frontend_Contacts_Controller::index')
|
||||
* class Contacts_Index_Action extends Spa_Action {
|
||||
* async on_load() {
|
||||
* this.data.contacts = await Contacts_Controller.fetch_all();
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
class Spa_Action extends Component {
|
||||
// constructor(args = {}, options = {}) {
|
||||
// super(args, options);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Called during load phase to fetch data from server
|
||||
* Set this.data properties here
|
||||
*/
|
||||
async on_load() {
|
||||
// Override in subclass
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL for this action class with given parameters
|
||||
* Static method for use without an instance
|
||||
*/
|
||||
static url(params = {}) {
|
||||
const that = this;
|
||||
|
||||
// Get routes from decorator metadata
|
||||
const routes = that._spa_routes || [];
|
||||
|
||||
if (routes.length === 0) {
|
||||
console.error(`Action ${that.name} has no routes defined`);
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Use first route as the pattern
|
||||
// TODO: Implement smart route selection based on params
|
||||
const pattern = routes[0];
|
||||
|
||||
return Spa.generate_url_from_pattern(pattern, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to this action with given parameters
|
||||
* Static method for programmatic navigation
|
||||
*/
|
||||
static dispatch(params = {}) {
|
||||
const that = this;
|
||||
const url = that.url(params);
|
||||
Spa.dispatch(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method: Generate URL with current args merged with new params
|
||||
*/
|
||||
url(params = {}) {
|
||||
const merged_params = { ...this.args, ...params };
|
||||
return this.constructor.url(merged_params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance method: Navigate with current args merged with new params
|
||||
*/
|
||||
dispatch(params = {}) {
|
||||
const url = this.url(params);
|
||||
Spa.dispatch(url);
|
||||
}
|
||||
}
|
||||
18
app/RSpade/Core/SPA/Spa_App.blade.php
Executable file
18
app/RSpade/Core/SPA/Spa_App.blade.php
Executable file
@@ -0,0 +1,18 @@
|
||||
@rsx_id('Spa_App')
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<meta content="ie=edge" http-equiv="X-UA-Compatible">
|
||||
|
||||
{{-- Bundle includes --}}
|
||||
{!! Frontend_Bundle::render() !!}
|
||||
</head>
|
||||
|
||||
<body class="{{ rsx_body_class() }}">
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
78
app/RSpade/Core/SPA/Spa_Decorators.js
Executable file
78
app/RSpade/Core/SPA/Spa_Decorators.js
Executable file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Spa Decorator Functions
|
||||
*
|
||||
* These decorators are used on Spa Action classes to define their route patterns,
|
||||
* layouts, and associated PHP controller methods.
|
||||
*
|
||||
* Decorators store metadata as static properties on the class, which Spa.js
|
||||
* reads during initialization to register routes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Define route pattern(s) for this action
|
||||
*
|
||||
* Usage:
|
||||
* @route('/contacts')
|
||||
* @route('/contacts/index')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function route(pattern) {
|
||||
return function (target) {
|
||||
// Store route pattern on the class
|
||||
if (!target._spa_routes) {
|
||||
target._spa_routes = [];
|
||||
}
|
||||
target._spa_routes.push(pattern);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Define which layout this action renders within
|
||||
*
|
||||
* Usage:
|
||||
* @layout('Frontend_Layout')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function layout(layout_name) {
|
||||
return function (target) {
|
||||
// Store layout name on the class
|
||||
target._spa_layout = layout_name;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Link this Spa action to its PHP controller method
|
||||
* Used for generating PHP URLs and understanding the relationship
|
||||
*
|
||||
* Usage:
|
||||
* @spa('Frontend_Contacts_Controller::index')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function spa(controller_method) {
|
||||
return function (target) {
|
||||
// Store controller::method reference on the class
|
||||
target._spa_controller_method = controller_method;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Define the browser page title for this action
|
||||
*
|
||||
* Usage:
|
||||
* @title('Contacts - RSX')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
*/
|
||||
function title(page_title) {
|
||||
return function (target) {
|
||||
// Store page title on the class
|
||||
target._spa_title = page_title;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
150
app/RSpade/Core/SPA/Spa_Layout.js
Executable file
150
app/RSpade/Core/SPA/Spa_Layout.js
Executable file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Spa_Layout - Base class for Spa layouts
|
||||
*
|
||||
* Layouts provide the persistent wrapper (header, nav, footer) around actions.
|
||||
* They render directly to body and contain a content area where actions render.
|
||||
*
|
||||
* Requirements:
|
||||
* - Must have an element with $id="content" where actions will render
|
||||
* - Persists across action navigations (only re-created when layout changes)
|
||||
*
|
||||
* Lifecycle events triggered for actions:
|
||||
* - before_action_init, action_init
|
||||
* - before_action_render, action_render
|
||||
* - before_action_ready, action_ready
|
||||
*
|
||||
* Hook methods that can be overridden:
|
||||
* - on_action(url, action_name, args) - Called when new action is set
|
||||
*/
|
||||
class Spa_Layout extends Component {
|
||||
on_create() {
|
||||
console.log('Layout create!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content container where actions render
|
||||
* @returns {jQuery} The content element
|
||||
*/
|
||||
$content() {
|
||||
return this.$id('content');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set which action should be rendered
|
||||
* Called by Spa.dispatch() - stores action info for _run_action()
|
||||
*
|
||||
* @param {string} action_name - Name of the action class
|
||||
* @param {object} args - URL parameters and query params
|
||||
* @param {string} url - The full URL being dispatched to
|
||||
*/
|
||||
_set_action(action_name, args, url) {
|
||||
this._pending_action_name = action_name;
|
||||
this._pending_action_args = args;
|
||||
this._pending_action_url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the pending action - stop old action, create new one
|
||||
* Called by Spa.dispatch() after _set_action()
|
||||
*/
|
||||
async _run_action() {
|
||||
const action_name = this._pending_action_name;
|
||||
const args = this._pending_action_args;
|
||||
const url = this._pending_action_url;
|
||||
|
||||
// Get content container
|
||||
console.log('[Spa_Layout] Looking for content element...');
|
||||
console.log('[Spa_Layout] this.$id available?', typeof this.$id);
|
||||
console.log('[Spa_Layout] this.$ exists?', !!this.$);
|
||||
|
||||
const $content = this.$content();
|
||||
console.log('[Spa_Layout] $content result:', $content);
|
||||
console.log('[Spa_Layout] $content.length:', $content?.length);
|
||||
|
||||
if (!$content || !$content.length) {
|
||||
// TODO: Better error handling - show error UI instead of just console
|
||||
console.error(`[Spa_Layout] Layout ${this.constructor.name} must have an element with $id="content"`);
|
||||
console.error(
|
||||
'[Spa_Layout] Available elements in this.$:',
|
||||
this.$.find('[data-id]')
|
||||
.toArray()
|
||||
.map((el) => el.getAttribute('data-id'))
|
||||
);
|
||||
throw new Error(`Layout ${this.constructor.name} must have an element with $id="content"`);
|
||||
}
|
||||
|
||||
// Stop old action (jqhtml auto-stops when .component() replaces)
|
||||
// Clear content area
|
||||
$content.empty();
|
||||
|
||||
// Get the action class to check for @title decorator
|
||||
const action_class = Manifest.get_class_by_name(action_name);
|
||||
|
||||
// Update page title if @title decorator is present (optional), clear if not
|
||||
if (action_class._spa_title) {
|
||||
document.title = action_class._spa_title;
|
||||
} else {
|
||||
document.title = '';
|
||||
}
|
||||
|
||||
// Create new action component
|
||||
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
|
||||
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
|
||||
const action = $content.component(action_name, args).component();
|
||||
|
||||
// Store reference
|
||||
Spa.action = action;
|
||||
this.action = action;
|
||||
|
||||
// Call on_action hook (can be overridden by subclasses)
|
||||
this.on_action(url, action_name, args);
|
||||
this.trigger('action');
|
||||
|
||||
// Setup event forwarding from action to layout
|
||||
// Action triggers 'init' -> Layout triggers 'action_init'
|
||||
this._setup_action_events(action);
|
||||
|
||||
// Wait for action to be ready
|
||||
await action.ready();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners on action to forward to layout
|
||||
* @private
|
||||
*/
|
||||
_setup_action_events(action) {
|
||||
const events = ['before_init', 'init', 'before_render', 'render', 'before_ready', 'ready'];
|
||||
|
||||
events.forEach((event) => {
|
||||
action.on(event, () => {
|
||||
// Trigger corresponding layout event with 'action_' prefix
|
||||
const layout_event = event.replace('before_', 'before_action_').replace(/^(?!before)/, 'action_');
|
||||
this.trigger(layout_event, action);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook called when a new action is set
|
||||
* Override this in subclasses to react to action changes.
|
||||
*
|
||||
* on_action can / should be implemented as a async function. but nothing is waiting for it, this is
|
||||
* just defined this way for convienence to make clear that async code can be used and is appropriate
|
||||
* for on_action.
|
||||
*
|
||||
* If it is necessary to wait for the action itself to reach ready state, add:
|
||||
* await this.action.ready();
|
||||
* to the concrete implementation of on_action.
|
||||
*
|
||||
* @param {string} url - The URL being navigated to
|
||||
* @param {string} action_name - Name of the action class
|
||||
* @param {object} args - URL parameters and query params
|
||||
*/
|
||||
async on_action(url, action_name, args) {
|
||||
// Empty by default - override in subclass
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
console.log('layout ready!');
|
||||
}
|
||||
}
|
||||
180
app/RSpade/Core/SPA/Spa_ManifestSupport.php
Executable file
180
app/RSpade/Core/SPA/Spa_ManifestSupport.php
Executable file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\SPA;
|
||||
|
||||
use RuntimeException;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for extracting Spa route metadata from Spa_Action classes
|
||||
* This runs after the primary manifest is built to add Spa routes to the unified routes index
|
||||
*/
|
||||
class Spa_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Spa Routes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the manifest and build Spa routes index
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize routes key if not already set
|
||||
if (!isset($manifest_data['data']['routes'])) {
|
||||
$manifest_data['data']['routes'] = [];
|
||||
}
|
||||
|
||||
// Get all files to look up PHP controller metadata
|
||||
$files = $manifest_data['data']['files'];
|
||||
|
||||
// Get all JavaScript classes extending Spa_Action
|
||||
$action_classes = Manifest::js_get_extending('Spa_Action');
|
||||
|
||||
foreach ($action_classes as $class_name => $action_metadata) {
|
||||
// Extract decorator metadata
|
||||
$decorators = $action_metadata['decorators'] ?? [];
|
||||
|
||||
// Parse decorators into route configuration
|
||||
$route_info = static::_parse_decorators($decorators);
|
||||
|
||||
// Skip if no route decorator found
|
||||
if (empty($route_info['routes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate that @spa decorator is present
|
||||
if (empty($route_info['spa_controller']) || empty($route_info['spa_method'])) {
|
||||
throw new RuntimeException(
|
||||
"Spa action '{$class_name}' is missing required @spa decorator.\n" .
|
||||
"Add @spa('Controller_Class::method') to specify the PHP controller method that serves the Spa bootstrap.\n" .
|
||||
"File: {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Find the PHP controller file and metadata
|
||||
$php_controller_class = $route_info['spa_controller'];
|
||||
$php_controller_method = $route_info['spa_method'];
|
||||
$php_controller_file = null;
|
||||
$php_controller_fqcn = null;
|
||||
|
||||
// Search for the controller in the manifest
|
||||
foreach ($files as $file => $metadata) {
|
||||
if (($metadata['class'] ?? null) === $php_controller_class || ($metadata['fqcn'] ?? null) === $php_controller_class) {
|
||||
$php_controller_file = $file;
|
||||
$php_controller_fqcn = $metadata['fqcn'] ?? $metadata['class'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$php_controller_file) {
|
||||
throw new RuntimeException(
|
||||
"Spa action '{$class_name}' references unknown controller '{$php_controller_class}'.\n" .
|
||||
"The @spa decorator must reference a valid PHP controller class.\n" .
|
||||
"File: {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Extract Auth attributes from the PHP controller method
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$php_controller_file] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$php_controller_method]['attributes']['Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$php_controller_method]['attributes']['Auth'];
|
||||
}
|
||||
|
||||
// Build complete route metadata for each route pattern
|
||||
foreach ($route_info['routes'] as $route_pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($route_pattern[0] !== '/') {
|
||||
$route_pattern = '/' . $route_pattern;
|
||||
}
|
||||
|
||||
// Check for duplicate route definition (pattern must be unique across all route types)
|
||||
if (isset($manifest_data['data']['routes'][$route_pattern])) {
|
||||
$existing = $manifest_data['data']['routes'][$route_pattern];
|
||||
$existing_type = $existing['type'];
|
||||
$existing_location = $existing_type === 'spa'
|
||||
? "Spa action {$existing['js_action_class']} in {$existing['file']}"
|
||||
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
|
||||
|
||||
throw new RuntimeException(
|
||||
"Duplicate route definition: {$route_pattern}\n" .
|
||||
" Already defined: {$existing_location}\n" .
|
||||
" Conflicting: Spa action {$class_name} in {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Store route with unified structure
|
||||
$manifest_data['data']['routes'][$route_pattern] = [
|
||||
'methods' => ['GET'], // Spa routes are always GET
|
||||
'type' => 'spa',
|
||||
'class' => $php_controller_fqcn,
|
||||
'method' => $php_controller_method,
|
||||
'name' => null,
|
||||
'file' => $php_controller_file,
|
||||
'require' => $require_attrs,
|
||||
'js_action_class' => $class_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse decorator metadata into route configuration
|
||||
*
|
||||
* @param array $decorators Array of decorator data from manifest
|
||||
* @return array Parsed route configuration
|
||||
*/
|
||||
private static function _parse_decorators(array $decorators): array
|
||||
{
|
||||
$config = [
|
||||
'routes' => [],
|
||||
'layout' => null,
|
||||
'spa_controller' => null,
|
||||
'spa_method' => null,
|
||||
];
|
||||
|
||||
foreach ($decorators as $decorator) {
|
||||
[$name, $args] = $decorator;
|
||||
|
||||
switch ($name) {
|
||||
case 'route':
|
||||
// @route('/path') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$config['routes'][] = $args[0];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'layout':
|
||||
// @layout('Layout_Name') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$config['layout'] = $args[0];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spa':
|
||||
// @spa('Controller::method') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$parts = explode('::', $args[0]);
|
||||
if (count($parts) === 2) {
|
||||
$config['spa_controller'] = $parts[0];
|
||||
$config['spa_method'] = $parts[1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ class Search_Index_Model extends Rsx_Site_Model_Abstract
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'search_indexes';
|
||||
protected $table = '_search_indexes';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
namespace App\RSpade\Core\Service;
|
||||
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
/**
|
||||
* Base service class for all RSX services
|
||||
*
|
||||
@@ -21,10 +23,11 @@ abstract class Rsx_Service_Abstract
|
||||
* Pre-task hook called before any task execution
|
||||
* Override in child classes to add pre-task logic
|
||||
*
|
||||
* @param Task_Instance $task Task instance for logging and status tracking
|
||||
* @param array $params Task parameters
|
||||
* @return mixed|null Return null to continue, or throw exception to halt
|
||||
*/
|
||||
public static function pre_task(array $params = [])
|
||||
public static function pre_task(Task_Instance $task, array $params = [])
|
||||
{
|
||||
// Default implementation does nothing
|
||||
// Override in child classes to add authentication, validation, logging, etc.
|
||||
|
||||
64
app/RSpade/Core/Session/Session_Cleanup_Service.php
Executable file
64
app/RSpade/Core/Session/Session_Cleanup_Service.php
Executable file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Session;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
/**
|
||||
* Session_Cleanup_Service
|
||||
*
|
||||
* Scheduled cleanup of expired and abandoned sessions.
|
||||
*
|
||||
* Cleanup Rules:
|
||||
* - Logged-in sessions (login_user_id set): Delete if older than 365 days
|
||||
* - Anonymous sessions (login_user_id null): Delete if older than 14 days
|
||||
*
|
||||
* Runs daily at 3 AM via scheduled task.
|
||||
*/
|
||||
class Session_Cleanup_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
/**
|
||||
* Clean up expired and abandoned sessions
|
||||
*
|
||||
* Deletes sessions based on age and login status:
|
||||
* - Logged-in sessions: 365 days retention
|
||||
* - Anonymous sessions: 14 days retention
|
||||
*
|
||||
* @param Task_Instance $task Task instance for logging
|
||||
* @param array $params Task parameters
|
||||
* @return array Cleanup statistics
|
||||
*/
|
||||
#[Task('Clean up expired and abandoned sessions (runs daily at 3 AM)')]
|
||||
#[Schedule('0 3 * * *')]
|
||||
public static function cleanup_sessions(Task_Instance $task, array $params = [])
|
||||
{
|
||||
// Logged-in sessions: older than 365 days
|
||||
$logged_in_cutoff = now()->subDays(365);
|
||||
$logged_in_deleted = DB::table('sessions')
|
||||
->whereNotNull('login_user_id')
|
||||
->where('last_active', '<', $logged_in_cutoff)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$logged_in_deleted} logged-in sessions older than 365 days");
|
||||
|
||||
// Anonymous sessions: older than 14 days
|
||||
$anonymous_cutoff = now()->subDays(14);
|
||||
$anonymous_deleted = DB::table('sessions')
|
||||
->whereNull('login_user_id')
|
||||
->where('last_active', '<', $anonymous_cutoff)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$anonymous_deleted} anonymous sessions older than 14 days");
|
||||
|
||||
$total_deleted = $logged_in_deleted + $anonymous_deleted;
|
||||
$task->info("Total sessions deleted: {$total_deleted}");
|
||||
|
||||
return [
|
||||
'logged_in_deleted' => $logged_in_deleted,
|
||||
'anonymous_deleted' => $anonymous_deleted,
|
||||
'total_deleted' => $total_deleted,
|
||||
];
|
||||
}
|
||||
}
|
||||
295
app/RSpade/Core/Task/CLAUDE.md
Executable file
295
app/RSpade/Core/Task/CLAUDE.md
Executable file
@@ -0,0 +1,295 @@
|
||||
# Task System
|
||||
|
||||
RSpade provides a unified task execution system supporting immediate CLI execution, queued async tasks, and scheduled cron-based tasks.
|
||||
|
||||
## Creating Task Services
|
||||
|
||||
Task services must extend `Rsx_Service_Abstract` and use attributes to define tasks:
|
||||
|
||||
```php
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
|
||||
class My_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
// Simple task (can be run via CLI or dispatched to queue)
|
||||
#[Task('Description of what this task does')]
|
||||
public static function my_task(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$task->info('Task is running');
|
||||
// Task logic here
|
||||
return ['result' => 'success'];
|
||||
}
|
||||
|
||||
// Scheduled task (runs automatically via cron)
|
||||
#[Task('Cleanup old records')]
|
||||
#[Schedule('0 2 * * *')] // Daily at 2 AM
|
||||
public static function cleanup(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$task->info('Cleanup running');
|
||||
// Cleanup logic here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Class MUST extend `Rsx_Service_Abstract`
|
||||
- Method MUST be `public static`
|
||||
- Method MUST have signature: `(Task_Instance $task, array $params = [])`
|
||||
- Method MUST have `#[Task('description')]` attribute
|
||||
- Scheduled tasks also need `#[Schedule('cron_expression')]` attribute
|
||||
|
||||
## Important Notes on Attributes
|
||||
|
||||
- Attributes work via reflection - NO backing PHP classes needed
|
||||
- The linter will remove `use` statements for attributes - this is INTENTIONAL and CORRECT
|
||||
- Use `#[Task]` and `#[Schedule]`, NOT `#[Task_Attribute]` or any other variation
|
||||
- Never create PHP classes for these attributes
|
||||
|
||||
## Task_Instance Methods
|
||||
|
||||
The `$task` parameter provides logging and progress tracking:
|
||||
|
||||
```php
|
||||
public static function process_items(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$items = Item_Model::all();
|
||||
$total = count($items);
|
||||
|
||||
$task->info("Processing {$total} items");
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
// Update progress
|
||||
$task->progress($index + 1, $total);
|
||||
|
||||
// Log info
|
||||
$task->info("Processing item {$item->id}");
|
||||
|
||||
// Log warnings
|
||||
if ($item->status === 'pending') {
|
||||
$task->warning("Item {$item->id} is still pending");
|
||||
}
|
||||
|
||||
// Process item
|
||||
$item->process();
|
||||
}
|
||||
|
||||
$task->info("Completed processing {$total} items");
|
||||
|
||||
return ['processed' => $total];
|
||||
}
|
||||
```
|
||||
|
||||
## Running Tasks
|
||||
|
||||
### CLI Execution
|
||||
|
||||
```bash
|
||||
# List all available tasks
|
||||
php artisan rsx:task:list
|
||||
|
||||
# Run task immediately from CLI (synchronous)
|
||||
php artisan rsx:task:run Service_Class method_name
|
||||
|
||||
# Run with parameters
|
||||
php artisan rsx:task:run Service_Class method_name '{"param":"value"}'
|
||||
```
|
||||
|
||||
### Programmatic Dispatch
|
||||
|
||||
```php
|
||||
// Dispatch task to queue (async)
|
||||
use App\RSpade\Core\Task\Task;
|
||||
|
||||
Task::dispatch('Service_Class', 'method_name', ['param' => 'value']);
|
||||
|
||||
// Dispatch with delay
|
||||
Task::dispatch('Service_Class', 'method_name', ['param' => 'value'], 60); // 60 second delay
|
||||
```
|
||||
|
||||
### Scheduled Execution
|
||||
|
||||
Tasks with `#[Schedule]` attribute run automatically via cron.
|
||||
|
||||
Add to crontab:
|
||||
```cron
|
||||
* * * * * cd /var/www/html && php artisan rsx:task:process
|
||||
```
|
||||
|
||||
This runs every minute and:
|
||||
1. Processes any queued tasks
|
||||
2. Runs scheduled tasks that are due
|
||||
|
||||
## Cron Expression Syntax
|
||||
|
||||
```
|
||||
┌───────────── minute (0 - 59)
|
||||
│ ┌───────────── hour (0 - 23)
|
||||
│ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday)
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
* * * * *
|
||||
```
|
||||
|
||||
Common patterns:
|
||||
- `*/5 * * * *` - Every 5 minutes
|
||||
- `*/30 * * * *` - Every 30 minutes
|
||||
- `0 * * * *` - Every hour
|
||||
- `0 2 * * *` - Daily at 2 AM
|
||||
- `0 */6 * * *` - Every 6 hours
|
||||
- `0 0 * * 0` - Weekly on Sunday at midnight
|
||||
- `0 0 1 * *` - Monthly on the 1st at midnight
|
||||
- `30 2 * * 1-5` - Weekdays at 2:30 AM
|
||||
|
||||
## Task Queue
|
||||
|
||||
Tasks dispatched via `Task::dispatch()` are stored in the database queue:
|
||||
|
||||
```sql
|
||||
task_queue
|
||||
├── id (bigint)
|
||||
├── service_class (varchar)
|
||||
├── method_name (varchar)
|
||||
├── parameters (json)
|
||||
├── status (enum: pending, processing, completed, failed)
|
||||
├── attempts (int)
|
||||
├── run_at (timestamp)
|
||||
├── started_at (timestamp)
|
||||
├── completed_at (timestamp)
|
||||
├── error_message (text)
|
||||
└── timestamps
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Tasks that throw exceptions:
|
||||
1. Are marked as `failed` in the queue
|
||||
2. Error message is logged
|
||||
3. Can be retried manually via CLI
|
||||
4. Maximum 3 automatic retry attempts
|
||||
|
||||
```php
|
||||
public static function risky_task(Task_Instance $task, array $params = [])
|
||||
{
|
||||
try {
|
||||
// Risky operation
|
||||
$result = External_API::call();
|
||||
} catch (Exception $e) {
|
||||
$task->error("API call failed: " . $e->getMessage());
|
||||
throw $e; // Re-throw to mark task as failed
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep tasks idempotent** - Safe to run multiple times
|
||||
2. **Log progress** for long-running tasks
|
||||
3. **Return meaningful data** for debugging
|
||||
4. **Use transactions** for database operations
|
||||
5. **Set appropriate schedules** to avoid overlap
|
||||
6. **Handle exceptions gracefully**
|
||||
7. **Keep tasks focused** - one task, one purpose
|
||||
|
||||
## Common Task Patterns
|
||||
|
||||
### Cleanup Task
|
||||
```php
|
||||
#[Task('Clean old logs')]
|
||||
#[Schedule('0 3 * * *')] // Daily at 3 AM
|
||||
public static function cleanup_logs(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$cutoff = now()->subDays(30);
|
||||
$deleted = Log_Model::where('created_at', '<', $cutoff)->delete();
|
||||
|
||||
$task->info("Deleted {$deleted} old log entries");
|
||||
|
||||
return ['deleted' => $deleted];
|
||||
}
|
||||
```
|
||||
|
||||
### Import Task
|
||||
```php
|
||||
#[Task('Import data from CSV')]
|
||||
public static function import_csv(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$file_path = $params['file_path'] ?? null;
|
||||
|
||||
if (!$file_path || !file_exists($file_path)) {
|
||||
throw new Exception("File not found: {$file_path}");
|
||||
}
|
||||
|
||||
$handle = fopen($file_path, 'r');
|
||||
$row_count = 0;
|
||||
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
$row_count++;
|
||||
$task->progress($row_count);
|
||||
|
||||
// Process row
|
||||
Model::create([
|
||||
'field1' => $data[0],
|
||||
'field2' => $data[1],
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
$task->info("Imported {$row_count} rows");
|
||||
|
||||
return ['imported' => $row_count];
|
||||
}
|
||||
```
|
||||
|
||||
### Report Generation
|
||||
```php
|
||||
#[Task('Generate monthly report')]
|
||||
#[Schedule('0 0 1 * *')] // First day of month at midnight
|
||||
public static function monthly_report(Task_Instance $task, array $params = [])
|
||||
{
|
||||
$start = now()->subMonth()->startOfMonth();
|
||||
$end = now()->subMonth()->endOfMonth();
|
||||
|
||||
$task->info("Generating report for {$start->format('F Y')}");
|
||||
|
||||
$data = Sales_Model::whereBetween('created_at', [$start, $end])
|
||||
->get();
|
||||
|
||||
$report = Report_Generator::create($data);
|
||||
$report->save(storage_path("reports/monthly-{$start->format('Y-m')}.pdf"));
|
||||
|
||||
$task->info("Report generated successfully");
|
||||
|
||||
return [
|
||||
'period' => $start->format('F Y'),
|
||||
'records' => count($data),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring Tasks
|
||||
|
||||
View task history and status:
|
||||
|
||||
```bash
|
||||
# View pending tasks
|
||||
php artisan rsx:task:pending
|
||||
|
||||
# View failed tasks
|
||||
php artisan rsx:task:failed
|
||||
|
||||
# Retry failed task
|
||||
php artisan rsx:task:retry {task_id}
|
||||
|
||||
# Clear completed tasks older than 30 days
|
||||
php artisan rsx:task:clear --days=30
|
||||
```
|
||||
|
||||
## Service Discovery
|
||||
|
||||
The manifest system automatically discovers all services extending `Rsx_Service_Abstract` in directories configured for scanning. Ensure your service directory is included in the manifest's `scan_directories` configuration.
|
||||
202
app/RSpade/Core/Task/Cron_Parser.php
Executable file
202
app/RSpade/Core/Task/Cron_Parser.php
Executable file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Cron_Parser
|
||||
*
|
||||
* Parses cron expressions and calculates next run times.
|
||||
* Supports standard cron syntax: minute hour day month weekday
|
||||
*
|
||||
* Examples:
|
||||
* Every minute: "* * * * *"
|
||||
* Every hour at minute 0: "0 * * * *"
|
||||
* Daily at midnight: "0 0 * * *"
|
||||
* Daily at 3 AM: "0 3 * * *"
|
||||
* Weekly on Sunday at midnight: "0 0 * * 0"
|
||||
* Monthly on the 1st at midnight: "0 0 1 * *"
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Cron_Parser
|
||||
{
|
||||
private array $parts;
|
||||
private string $expression;
|
||||
|
||||
/**
|
||||
* Parse a cron expression
|
||||
*
|
||||
* @param string $expression Cron expression (minute hour day month weekday)
|
||||
* @throws \InvalidArgumentException If expression is invalid
|
||||
*/
|
||||
public function __construct(string $expression)
|
||||
{
|
||||
$this->expression = $expression;
|
||||
$this->parts = $this->parse($expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cron expression into components
|
||||
*
|
||||
* @param string $expression
|
||||
* @return array ['minute' => [...], 'hour' => [...], 'day' => [...], 'month' => [...], 'weekday' => [...]]
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
private function parse(string $expression): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($expression));
|
||||
|
||||
if (count($parts) !== 5) {
|
||||
throw new \InvalidArgumentException("Invalid cron expression: {$expression}. Expected 5 parts (minute hour day month weekday)");
|
||||
}
|
||||
|
||||
return [
|
||||
'minute' => $this->parse_field($parts[0], 0, 59),
|
||||
'hour' => $this->parse_field($parts[1], 0, 23),
|
||||
'day' => $this->parse_field($parts[2], 1, 31),
|
||||
'month' => $this->parse_field($parts[3], 1, 12),
|
||||
'weekday' => $this->parse_field($parts[4], 0, 6), // 0 = Sunday
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single cron field
|
||||
*
|
||||
* Supports: asterisk, numbers, step values, ranges, lists
|
||||
*
|
||||
* @param string $field Field value from cron expression
|
||||
* @param int $min Minimum allowed value
|
||||
* @param int $max Maximum allowed value
|
||||
* @return array Array of allowed values
|
||||
*/
|
||||
private function parse_field(string $field, int $min, int $max): array
|
||||
{
|
||||
// Asterisk means all values
|
||||
if ($field === '*') {
|
||||
return range($min, $max);
|
||||
}
|
||||
|
||||
// Step values (e.g., */15)
|
||||
if (preg_match('/^\*\/(\d+)$/', $field, $matches)) {
|
||||
$step = (int) $matches[1];
|
||||
return range($min, $max, $step);
|
||||
}
|
||||
|
||||
// Range (e.g., 1-5)
|
||||
if (preg_match('/^(\d+)-(\d+)$/', $field, $matches)) {
|
||||
$start = (int) $matches[1];
|
||||
$end = (int) $matches[2];
|
||||
|
||||
if ($start < $min || $end > $max || $start > $end) {
|
||||
throw new \InvalidArgumentException("Invalid range in cron expression: {$field}");
|
||||
}
|
||||
|
||||
return range($start, $end);
|
||||
}
|
||||
|
||||
// List (e.g., 1,3,5)
|
||||
if (str_contains($field, ',')) {
|
||||
$values = array_map('intval', explode(',', $field));
|
||||
|
||||
foreach ($values as $value) {
|
||||
if ($value < $min || $value > $max) {
|
||||
throw new \InvalidArgumentException("Invalid value in cron expression: {$value}");
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
// Single value
|
||||
$value = (int) $field;
|
||||
|
||||
if ($value < $min || $value > $max) {
|
||||
throw new \InvalidArgumentException("Invalid value in cron expression: {$value}");
|
||||
}
|
||||
|
||||
return [$value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next run time from a given timestamp
|
||||
*
|
||||
* @param int|null $from_timestamp Start time (default: current time)
|
||||
* @return int Next run timestamp
|
||||
*/
|
||||
public function get_next_run_time(?int $from_timestamp = null): int
|
||||
{
|
||||
if ($from_timestamp === null) {
|
||||
$from_timestamp = time();
|
||||
}
|
||||
|
||||
// Start from the next minute (cron runs at most once per minute)
|
||||
$current = new DateTime('@' . $from_timestamp);
|
||||
$current->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
||||
$current->modify('+1 minute');
|
||||
$current->setTime((int) $current->format('H'), (int) $current->format('i'), 0);
|
||||
|
||||
// Try up to 4 years in the future (should be more than enough)
|
||||
$max_iterations = 60 * 24 * 365 * 4;
|
||||
$iterations = 0;
|
||||
|
||||
while ($iterations < $max_iterations) {
|
||||
if ($this->matches($current)) {
|
||||
return $current->getTimestamp();
|
||||
}
|
||||
|
||||
$current->modify('+1 minute');
|
||||
$iterations++;
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Could not calculate next run time for cron expression: {$this->expression}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a datetime matches the cron expression
|
||||
*
|
||||
* @param DateTime $datetime
|
||||
* @return bool
|
||||
*/
|
||||
private function matches(DateTime $datetime): bool
|
||||
{
|
||||
$minute = (int) $datetime->format('i');
|
||||
$hour = (int) $datetime->format('H');
|
||||
$day = (int) $datetime->format('d');
|
||||
$month = (int) $datetime->format('n');
|
||||
$weekday = (int) $datetime->format('w');
|
||||
|
||||
return in_array($minute, $this->parts['minute'])
|
||||
&& in_array($hour, $this->parts['hour'])
|
||||
&& in_array($day, $this->parts['day'])
|
||||
&& in_array($month, $this->parts['month'])
|
||||
&& in_array($weekday, $this->parts['weekday']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression without creating an instance
|
||||
*
|
||||
* @param string $expression
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_valid(string $expression): bool
|
||||
{
|
||||
try {
|
||||
new self($expression);
|
||||
return true;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original cron expression
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_expression(): string
|
||||
{
|
||||
return $this->expression;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
|
||||
/**
|
||||
* Task - Unified task execution system
|
||||
@@ -92,23 +95,45 @@ class Task
|
||||
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
|
||||
}
|
||||
|
||||
// Call pre_task() if exists
|
||||
if (method_exists($service_class, 'pre_task')) {
|
||||
$pre_result = $service_class::pre_task($params);
|
||||
if ($pre_result !== null) {
|
||||
// pre_task returned something, use that as response
|
||||
return $pre_result;
|
||||
// Create task instance for immediate execution
|
||||
$task_instance = new Task_Instance(
|
||||
$service_class,
|
||||
$rsx_task,
|
||||
$params,
|
||||
'default',
|
||||
true // immediate execution
|
||||
);
|
||||
|
||||
// Mark as started
|
||||
$task_instance->mark_started();
|
||||
|
||||
try {
|
||||
// Call pre_task() if exists
|
||||
if (method_exists($service_class, 'pre_task')) {
|
||||
$pre_result = $service_class::pre_task($task_instance, $params);
|
||||
if ($pre_result !== null) {
|
||||
// pre_task returned something, use that as response
|
||||
$task_instance->mark_completed($pre_result);
|
||||
return $pre_result;
|
||||
}
|
||||
}
|
||||
|
||||
// Call the actual task method
|
||||
$response = $service_class::$rsx_task($task_instance, $params);
|
||||
|
||||
// Mark as completed
|
||||
$task_instance->mark_completed($response);
|
||||
|
||||
// Filter response through JSON encode/decode to remove PHP objects
|
||||
// (similar to Ajax behavior)
|
||||
$filtered_response = json_decode(json_encode($response), true);
|
||||
|
||||
return $filtered_response;
|
||||
} catch (Exception $e) {
|
||||
// Mark as failed
|
||||
$task_instance->mark_failed($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Call the actual task method
|
||||
$response = $service_class::$rsx_task($params);
|
||||
|
||||
// Filter response through JSON encode/decode to remove PHP objects
|
||||
// (similar to Ajax behavior)
|
||||
$filtered_response = json_decode(json_encode($response), true);
|
||||
|
||||
return $filtered_response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,4 +150,195 @@ class Task
|
||||
'result' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a task to the queue for async execution
|
||||
*
|
||||
* Creates a database record for the task and returns the task ID.
|
||||
* Task will be picked up and executed by the task processor (rsx:task:process).
|
||||
*
|
||||
* @param string $rsx_service Service name (e.g., 'Seeder_Service')
|
||||
* @param string $rsx_task Task/method name (e.g., 'seed_clients')
|
||||
* @param array $params Parameters to pass to the task
|
||||
* @param array $options Optional task options:
|
||||
* - 'queue' => Queue name (default: 'default')
|
||||
* - 'scheduled_for' => Timestamp when task should run (default: now)
|
||||
* - 'timeout' => Maximum execution time in seconds (default: from config)
|
||||
* @return int Task ID
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function dispatch(string $rsx_service, string $rsx_task, array $params = [], array $options = []): int
|
||||
{
|
||||
// Get manifest to find service
|
||||
$manifest = Manifest::get_all();
|
||||
$service_class = null;
|
||||
$file_info = null;
|
||||
|
||||
// Search for service class in manifest
|
||||
foreach ($manifest as $file_path => $info) {
|
||||
// Skip non-PHP files or files without classes
|
||||
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if class name matches exactly (without namespace)
|
||||
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
|
||||
|
||||
if ($class_basename === $rsx_service) {
|
||||
$service_class = $info['fqcn'];
|
||||
$file_info = $info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$service_class) {
|
||||
throw new Exception("Service class not found: {$rsx_service}");
|
||||
}
|
||||
|
||||
// Check if class exists
|
||||
if (!class_exists($service_class)) {
|
||||
throw new Exception("Service class does not exist: {$service_class}");
|
||||
}
|
||||
|
||||
// Check if it's a subclass of Rsx_Service_Abstract
|
||||
if (!Manifest::php_is_subclass_of($service_class, Rsx_Service_Abstract::class)) {
|
||||
throw new Exception("Service {$service_class} must extend Rsx_Service_Abstract");
|
||||
}
|
||||
|
||||
// Check if method exists and has Task attribute
|
||||
if (!isset($file_info['public_static_methods'][$rsx_task])) {
|
||||
throw new Exception("Task {$rsx_task} not found in service {$service_class}");
|
||||
}
|
||||
|
||||
$method_info = $file_info['public_static_methods'][$rsx_task];
|
||||
$has_task = false;
|
||||
|
||||
// Check for Task attribute in method metadata
|
||||
if (isset($method_info['attributes'])) {
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
|
||||
$has_task = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$has_task) {
|
||||
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
|
||||
}
|
||||
|
||||
// Create task instance
|
||||
$instance = new Task_Instance(
|
||||
$service_class,
|
||||
$rsx_task,
|
||||
$params,
|
||||
$options['queue'] ?? 'default',
|
||||
false // not immediate
|
||||
);
|
||||
|
||||
// Create database record
|
||||
$data = [
|
||||
'class' => $service_class,
|
||||
'method' => $rsx_task,
|
||||
'queue' => $options['queue'] ?? 'default',
|
||||
'status' => Task_Status::PENDING,
|
||||
'params' => json_encode($params),
|
||||
'scheduled_for' => $options['scheduled_for'] ?? now(),
|
||||
'timeout' => $options['timeout'] ?? config('rsx.tasks.default_timeout'),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$task_id = DB::table('_task_queue')->insertGetId($data);
|
||||
|
||||
return $task_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of a task
|
||||
*
|
||||
* Returns task information including status, logs, result, and error.
|
||||
*
|
||||
* @param int $task_id Task ID
|
||||
* @return array|null Task status data or null if not found
|
||||
*/
|
||||
public static function status(int $task_id): ?array
|
||||
{
|
||||
$row = DB::table('_task_queue')->where('id', $task_id)->first();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'class' => $row->class,
|
||||
'method' => $row->method,
|
||||
'queue' => $row->queue,
|
||||
'status' => $row->status,
|
||||
'params' => json_decode($row->params, true),
|
||||
'result' => json_decode($row->result, true),
|
||||
'logs' => $row->logs ? explode("\n", $row->logs) : [],
|
||||
'error' => $row->error,
|
||||
'scheduled_for' => $row->scheduled_for,
|
||||
'started_at' => $row->started_at,
|
||||
'completed_at' => $row->completed_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scheduled tasks from manifest
|
||||
*
|
||||
* Scans the manifest for methods with #[Schedule] attribute
|
||||
* and returns information about each scheduled task.
|
||||
*
|
||||
* @return array Array of scheduled task definitions
|
||||
*/
|
||||
public static function get_scheduled_tasks(): array
|
||||
{
|
||||
$manifest = Manifest::get_all();
|
||||
$scheduled_tasks = [];
|
||||
|
||||
foreach ($manifest as $file_path => $info) {
|
||||
// Skip non-PHP files or files without classes
|
||||
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a service class
|
||||
if (!isset($info['public_static_methods'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($info['public_static_methods'] as $method_name => $method_info) {
|
||||
// Check for Schedule attribute
|
||||
if (!isset($method_info['attributes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||
if ($attr_name === 'Schedule' || str_ends_with($attr_name, '\\Schedule')) {
|
||||
// Found a scheduled task
|
||||
foreach ($attr_instances as $attr_instance) {
|
||||
$cron_expression = $attr_instance[0] ?? null;
|
||||
$queue = $attr_instance[1] ?? 'scheduled';
|
||||
|
||||
if ($cron_expression) {
|
||||
$scheduled_tasks[] = [
|
||||
'class' => $info['fqcn'],
|
||||
'method' => $method_name,
|
||||
'cron_expression' => $cron_expression,
|
||||
'queue' => $queue,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $scheduled_tasks;
|
||||
}
|
||||
}
|
||||
|
||||
446
app/RSpade/Core/Task/Task_Instance.php
Executable file
446
app/RSpade/Core/Task/Task_Instance.php
Executable file
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
|
||||
/**
|
||||
* Task_Instance
|
||||
*
|
||||
* Represents a single task execution instance with logging, status tracking,
|
||||
* and temp directory management. Passed to all task methods for tracking.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Task_Instance
|
||||
{
|
||||
private ?int $id;
|
||||
private string $class;
|
||||
private string $method;
|
||||
private string $queue;
|
||||
private array $params;
|
||||
private Task_Status $status;
|
||||
private array $logs = [];
|
||||
private ?string $temp_dir = null;
|
||||
private bool $is_immediate;
|
||||
|
||||
/**
|
||||
* Create a new task instance
|
||||
*
|
||||
* @param string $class Fully qualified class name
|
||||
* @param string $method Static method name
|
||||
* @param array $params Task parameters
|
||||
* @param string $queue Queue name
|
||||
* @param bool $is_immediate True for immediate execution, false for database-backed
|
||||
*/
|
||||
public function __construct(
|
||||
string $class,
|
||||
string $method,
|
||||
array $params = [],
|
||||
string $queue = 'default',
|
||||
bool $is_immediate = true
|
||||
) {
|
||||
$this->class = $class;
|
||||
$this->method = $method;
|
||||
$this->params = $params;
|
||||
$this->queue = $queue;
|
||||
$this->is_immediate = $is_immediate;
|
||||
$this->status = new Task_Status(Task_Status::PENDING);
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load task instance from database by ID
|
||||
*
|
||||
* @param int $id Task ID
|
||||
* @return self|null
|
||||
*/
|
||||
public static function find(int $id): ?self
|
||||
{
|
||||
$row = DB::table('_task_queue')->where('id', $id)->first();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$instance = new self(
|
||||
$row->class,
|
||||
$row->method,
|
||||
json_decode($row->params, true) ?? [],
|
||||
$row->queue,
|
||||
false
|
||||
);
|
||||
|
||||
$instance->id = $row->id;
|
||||
$instance->status = new Task_Status($row->status);
|
||||
$instance->logs = $row->logs ? explode("\n", $row->logs) : [];
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save task instance to database
|
||||
*
|
||||
* @return int Task ID
|
||||
*/
|
||||
public function save(): int
|
||||
{
|
||||
if ($this->is_immediate) {
|
||||
throw new Exception("Cannot save immediate task to database");
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class' => $this->class,
|
||||
'method' => $this->method,
|
||||
'queue' => $this->queue,
|
||||
'status' => $this->status->value(),
|
||||
'params' => json_encode($this->params),
|
||||
'logs' => implode("\n", $this->logs),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($this->id === null) {
|
||||
$data['created_at'] = now();
|
||||
$this->id = DB::table('_task_queue')->insertGetId($data);
|
||||
} else {
|
||||
DB::table('_task_queue')->where('id', $this->id)->update($data);
|
||||
}
|
||||
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status in database
|
||||
*
|
||||
* @param Task_Status $status New status
|
||||
*/
|
||||
public function update_status(Task_Status $status): void
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update(['status' => $status->value(), 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as started
|
||||
*/
|
||||
public function mark_started(): void
|
||||
{
|
||||
$this->update_status(new Task_Status(Task_Status::RUNNING));
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'started_at' => now(),
|
||||
'worker_pid' => getmypid(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as completed
|
||||
*
|
||||
* @param mixed $result Optional result data
|
||||
*/
|
||||
public function mark_completed($result = null): void
|
||||
{
|
||||
$this->update_status(new Task_Status(Task_Status::COMPLETED));
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
$update = [
|
||||
'completed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($result !== null) {
|
||||
$update['result'] = json_encode($result);
|
||||
}
|
||||
|
||||
DB::table('_task_queue')->where('id', $this->id)->update($update);
|
||||
}
|
||||
|
||||
$this->cleanup_temp_dir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as failed
|
||||
*
|
||||
* @param string $error Error message
|
||||
*/
|
||||
public function mark_failed(string $error): void
|
||||
{
|
||||
$this->update_status(new Task_Status(Task_Status::FAILED));
|
||||
$this->error("Task failed: {$error}");
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'error' => $error,
|
||||
'completed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->cleanup_temp_dir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log message
|
||||
*
|
||||
* @param string $level Log level (info, error, debug)
|
||||
* @param string $message Log message
|
||||
*/
|
||||
public function log(string $level, string $message): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$log_line = "[{$timestamp}] [{$level}] {$message}";
|
||||
$this->logs[] = $log_line;
|
||||
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'logs' => implode("\n", $this->logs),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
public function info(string $message): void
|
||||
{
|
||||
$this->log('info', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
public function error(string $message): void
|
||||
{
|
||||
$this->log('error', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
public function debug(string $message): void
|
||||
{
|
||||
$this->log('debug', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task progress percentage
|
||||
*
|
||||
* @param int $percent Progress percentage (0-100)
|
||||
* @param string|null $message Optional progress message
|
||||
*/
|
||||
public function update_progress(int $percent, ?string $message = null): void
|
||||
{
|
||||
$percent = max(0, min(100, $percent));
|
||||
|
||||
if ($message) {
|
||||
$this->info("Progress: {$percent}% - {$message}");
|
||||
} else {
|
||||
$this->info("Progress: {$percent}%");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set task result data
|
||||
*
|
||||
* @param mixed $result Result data (will be JSON-encoded)
|
||||
*/
|
||||
public function set_result($result): void
|
||||
{
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'result' => json_encode($result),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat to indicate task is still running
|
||||
*/
|
||||
public function heartbeat(): void
|
||||
{
|
||||
if (!$this->is_immediate && $this->id !== null) {
|
||||
DB::table('_task_queue')
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'last_heartbeat_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create temporary directory for this task
|
||||
*
|
||||
* @return string Absolute path to temp directory
|
||||
*/
|
||||
public function get_temp_dir(): string
|
||||
{
|
||||
if ($this->temp_dir !== null) {
|
||||
return $this->temp_dir;
|
||||
}
|
||||
|
||||
$base_temp_dir = storage_path('rsx-tmp/tasks');
|
||||
|
||||
if (!is_dir($base_temp_dir)) {
|
||||
mkdir($base_temp_dir, 0755, true);
|
||||
}
|
||||
|
||||
if ($this->is_immediate) {
|
||||
$dir_name = 'immediate_' . uniqid();
|
||||
} else {
|
||||
$dir_name = 'task_' . $this->id;
|
||||
}
|
||||
|
||||
$this->temp_dir = $base_temp_dir . '/' . $dir_name;
|
||||
|
||||
if (!is_dir($this->temp_dir)) {
|
||||
mkdir($this->temp_dir, 0755, true);
|
||||
}
|
||||
|
||||
return $this->temp_dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary directory
|
||||
*/
|
||||
public function cleanup_temp_dir(): void
|
||||
{
|
||||
if ($this->temp_dir === null || !is_dir($this->temp_dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->delete_directory_recursive($this->temp_dir);
|
||||
$this->temp_dir = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete directory and contents
|
||||
*
|
||||
* @param string $dir Directory path
|
||||
*/
|
||||
private function delete_directory_recursive(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->delete_directory_recursive($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task ID
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function get_id(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task class name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_class(): string
|
||||
{
|
||||
return $this->class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task method name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_method(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task parameters
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_params(): array
|
||||
{
|
||||
return $this->params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task queue name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_queue(): string
|
||||
{
|
||||
return $this->queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task status
|
||||
*
|
||||
* @return Task_Status
|
||||
*/
|
||||
public function get_status(): Task_Status
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all task logs
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_logs(): array
|
||||
{
|
||||
return $this->logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task is immediate (not database-backed)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_immediate(): bool
|
||||
{
|
||||
return $this->is_immediate;
|
||||
}
|
||||
}
|
||||
110
app/RSpade/Core/Task/Task_Lock.php
Executable file
110
app/RSpade/Core/Task/Task_Lock.php
Executable file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Task_Lock
|
||||
*
|
||||
* Manages MySQL advisory locks for atomic task queue operations.
|
||||
* Uses GET_LOCK() and RELEASE_LOCK() for distributed locking.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Task_Lock
|
||||
{
|
||||
private string $lock_name;
|
||||
private int $timeout;
|
||||
private bool $is_locked = false;
|
||||
|
||||
/**
|
||||
* Create a new task lock instance
|
||||
*
|
||||
* @param string $lock_name Unique lock identifier
|
||||
* @param int $timeout Lock timeout in seconds (default: 10)
|
||||
*/
|
||||
public function __construct(string $lock_name, int $timeout = 10)
|
||||
{
|
||||
$this->lock_name = $lock_name;
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the lock
|
||||
*
|
||||
* @return bool True if lock acquired, false if already held by another process
|
||||
*/
|
||||
public function acquire(): bool
|
||||
{
|
||||
if ($this->is_locked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = DB::selectOne("SELECT GET_LOCK(?, ?) as acquired", [
|
||||
$this->lock_name,
|
||||
$this->timeout,
|
||||
]);
|
||||
|
||||
$this->is_locked = (bool) $result->acquired;
|
||||
|
||||
return $this->is_locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock
|
||||
*
|
||||
* @return bool True if lock was released, false if lock was not held
|
||||
*/
|
||||
public function release(): bool
|
||||
{
|
||||
if (!$this->is_locked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = DB::selectOne("SELECT RELEASE_LOCK(?) as released", [
|
||||
$this->lock_name,
|
||||
]);
|
||||
|
||||
$was_released = (bool) $result->released;
|
||||
|
||||
if ($was_released) {
|
||||
$this->is_locked = false;
|
||||
}
|
||||
|
||||
return $was_released;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the lock is currently held by this instance
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_locked(): bool
|
||||
{
|
||||
return $this->is_locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the lock is currently held by ANY process
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_in_use(): bool
|
||||
{
|
||||
$result = DB::selectOne("SELECT IS_USED_LOCK(?) as in_use", [
|
||||
$this->lock_name,
|
||||
]);
|
||||
|
||||
return $result->in_use !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically release lock on destruction
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
if ($this->is_locked) {
|
||||
$this->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/RSpade/Core/Task/Task_Status.php
Executable file
71
app/RSpade/Core/Task/Task_Status.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
/**
|
||||
* Task_Status
|
||||
*
|
||||
* Value object representing task execution status.
|
||||
* Provides type-safe status constants and helper methods.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Task_Status
|
||||
{
|
||||
// Status constants
|
||||
public const PENDING = 'pending';
|
||||
public const RUNNING = 'running';
|
||||
public const COMPLETED = 'completed';
|
||||
public const FAILED = 'failed';
|
||||
public const STUCK = 'stuck';
|
||||
|
||||
private string $status;
|
||||
|
||||
public function __construct(string $status)
|
||||
{
|
||||
if (!in_array($status, [self::PENDING, self::RUNNING, self::COMPLETED, self::FAILED, self::STUCK])) {
|
||||
throw new \InvalidArgumentException("Invalid task status: {$status}");
|
||||
}
|
||||
|
||||
$this->status = $status;
|
||||
}
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function is_pending(): bool
|
||||
{
|
||||
return $this->status === self::PENDING;
|
||||
}
|
||||
|
||||
public function is_running(): bool
|
||||
{
|
||||
return $this->status === self::RUNNING;
|
||||
}
|
||||
|
||||
public function is_completed(): bool
|
||||
{
|
||||
return $this->status === self::COMPLETED;
|
||||
}
|
||||
|
||||
public function is_failed(): bool
|
||||
{
|
||||
return $this->status === self::FAILED;
|
||||
}
|
||||
|
||||
public function is_stuck(): bool
|
||||
{
|
||||
return $this->status === self::STUCK;
|
||||
}
|
||||
|
||||
public function is_terminal(): bool
|
||||
{
|
||||
return $this->is_completed() || $this->is_failed();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
}
|
||||
10
app/RSpade/Core/constants.php
Executable file
10
app/RSpade/Core/constants.php
Executable file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* RSpade Framework Constants
|
||||
*
|
||||
* Global constants used throughout the framework
|
||||
*/
|
||||
|
||||
// SPA view name
|
||||
define('SPA', 'Spa_App');
|
||||
@@ -318,7 +318,7 @@ class Ide_Helper_Controller
|
||||
// Priority 5: jqhtml JavaScript classes (when type is 'jqhtml_class')
|
||||
if ($type === 'jqhtml_class') {
|
||||
try {
|
||||
// Look for JS class extending Jqhtml_Component
|
||||
// Look for JS class extending Component
|
||||
$files = Manifest::get_all();
|
||||
|
||||
// Search for .js files containing the class
|
||||
@@ -329,9 +329,9 @@ class Ide_Helper_Controller
|
||||
if (file_exists($absolute_path)) {
|
||||
$content = file_get_contents($absolute_path);
|
||||
|
||||
// Check if this file contains a class with the right name extending Jqhtml_Component
|
||||
// Look for patterns like: class ComponentName extends Jqhtml_Component
|
||||
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Jqhtml_Component/', $content)) {
|
||||
// Check if this file contains a class with the right name extending Component
|
||||
// Look for patterns like: class ComponentName extends Component
|
||||
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Component/', $content)) {
|
||||
$lines = explode("\n", $content);
|
||||
$line_number = 1;
|
||||
|
||||
@@ -364,7 +364,7 @@ class Ide_Helper_Controller
|
||||
// Special case: if method_name is 'data', look for 'on_load' instead
|
||||
$search_method = ($method_name === 'data') ? 'on_load' : $method_name;
|
||||
|
||||
// Look for JS class extending Jqhtml_Component and find the method
|
||||
// Look for JS class extending Component and find the method
|
||||
$files = Manifest::get_all();
|
||||
|
||||
// Search for .js files containing the class
|
||||
@@ -375,8 +375,8 @@ class Ide_Helper_Controller
|
||||
if (file_exists($absolute_path)) {
|
||||
$content = file_get_contents($absolute_path);
|
||||
|
||||
// Check if this file contains a class with the right name extending Jqhtml_Component
|
||||
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Jqhtml_Component/', $content)) {
|
||||
// Check if this file contains a class with the right name extending Component
|
||||
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Component/', $content)) {
|
||||
$lines = explode("\n", $content);
|
||||
$line_number = 1;
|
||||
$in_class = false;
|
||||
|
||||
@@ -369,7 +369,7 @@ function handle_definition_service($data) {
|
||||
break;
|
||||
|
||||
case 'jqhtml_class':
|
||||
// Search for JavaScript class extending Jqhtml_Component
|
||||
// Search for JavaScript class extending Component
|
||||
foreach ($manifest['jqhtml']['components'] ?? [] as $component) {
|
||||
if ($component['name'] === $identifier && isset($component['js_file'])) {
|
||||
$result = [
|
||||
@@ -840,7 +840,7 @@ function try_resolve_js_class($identifier, $method_name, $files) {
|
||||
if (file_exists($absolute_path)) {
|
||||
$content = file_get_contents($absolute_path);
|
||||
|
||||
// Check for JavaScript class (not specifically Jqhtml_Component)
|
||||
// Check for JavaScript class (not specifically Component)
|
||||
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+([A-Za-z_][A-Za-z0-9_]*)/', $content, $matches)) {
|
||||
$lines = explode("\n", $content);
|
||||
$line_number = 1;
|
||||
@@ -872,7 +872,7 @@ function try_resolve_js_class($identifier, $method_name, $files) {
|
||||
*
|
||||
* Note: This resolver does NOT check inheritance chains. It's a simple file lookup.
|
||||
* VS Code extension should use js_is_subclass_of() RPC to determine if a class
|
||||
* is actually a Jqhtml_Component before choosing this resolver type.
|
||||
* is actually a Component before choosing this resolver type.
|
||||
*/
|
||||
function try_resolve_jqhtml_class($identifier, $method_name, $files) {
|
||||
// Load manifest to get js_classes index
|
||||
@@ -1055,7 +1055,7 @@ function try_resolve_jqhtml_method($identifier, $method_name, $files) {
|
||||
if (file_exists($absolute_path)) {
|
||||
$content = file_get_contents($absolute_path);
|
||||
|
||||
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Jqhtml_Component/', $content)) {
|
||||
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Component/', $content)) {
|
||||
$lines = explode("\n", $content);
|
||||
$line_number = 1;
|
||||
$in_class = false;
|
||||
@@ -1114,11 +1114,11 @@ function try_resolve_jqhtml_method($identifier, $method_name, $files) {
|
||||
*
|
||||
* 2. js_class - JavaScript class files (*.js, not .jqhtml)
|
||||
* - Searches for JS classes with extends clause
|
||||
* - Does NOT require extending Jqhtml_Component
|
||||
* - Does NOT require extending Component
|
||||
* - Returns: file path, line number, extends
|
||||
*
|
||||
* 3. jqhtml_class - jqhtml component JavaScript files (*.js)
|
||||
* - Searches for classes extending Jqhtml_Component
|
||||
* - Searches for classes extending Component
|
||||
* - Returns: file path, line number
|
||||
*
|
||||
* 4. view - Blade view templates (*.blade.php)
|
||||
@@ -1135,7 +1135,7 @@ function try_resolve_jqhtml_method($identifier, $method_name, $files) {
|
||||
*
|
||||
* 7. jqhtml_class_method - Methods in jqhtml component classes
|
||||
* - Requires both identifier and method parameters
|
||||
* - Searches within Jqhtml_Component subclasses
|
||||
* - Searches within Component subclasses
|
||||
* - Returns: file path, method line number
|
||||
*
|
||||
* CSV TYPE LISTS:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Jqhtml_Component - Base class for JQHTML components in RSX framework
|
||||
* Component - Base class for JQHTML components in RSX framework
|
||||
*
|
||||
* This class wraps the jqhtml.Component from the npm package and provides
|
||||
* the standard interface for RSX components following the Upper_Case naming convention.
|
||||
* the standard interface for RSX components.
|
||||
*
|
||||
* _Base_Jqhtml_Component is imported from npm via Jqhtml_Bundle.
|
||||
*
|
||||
* @Instantiatable
|
||||
*/
|
||||
class Jqhtml_Component extends _Base_Jqhtml_Component {}
|
||||
class Component extends _Base_Jqhtml_Component {}
|
||||
|
||||
// RSX manifest automatically makes classes global - no manual assignment needed
|
||||
@@ -25,7 +25,7 @@ class Jqhtml
|
||||
{
|
||||
if ($slot_content !== '') {
|
||||
return sprintf(
|
||||
'<div class="Jqhtml_Component_Init" data-component-init-name="%s" data-component-args="%s">%s</div>',
|
||||
'<div class="Component_Init" data-component-init-name="%s" data-component-args="%s">%s</div>',
|
||||
htmlspecialchars($component_name),
|
||||
htmlspecialchars(json_encode($args), ENT_QUOTES, 'UTF-8'),
|
||||
$slot_content
|
||||
@@ -33,7 +33,7 @@ class Jqhtml
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<div class="Jqhtml_Component_Init" data-component-init-name="%s" data-component-args="%s"></div>',
|
||||
'<div class="Component_Init" data-component-init-name="%s" data-component-args="%s"></div>',
|
||||
htmlspecialchars($component_name),
|
||||
htmlspecialchars(json_encode($args), ENT_QUOTES, 'UTF-8')
|
||||
);
|
||||
|
||||
@@ -149,13 +149,13 @@ class JqhtmlBladeCompiler
|
||||
}
|
||||
|
||||
// Build HTML attributes string
|
||||
// Handle class attribute specially to merge with Jqhtml_Component_Init
|
||||
$class_value = 'Jqhtml_Component_Init';
|
||||
// Handle class attribute specially to merge with Component_Init
|
||||
$class_value = 'Component_Init';
|
||||
if (isset($html_attrs['class'])) {
|
||||
if ($html_attrs['class']['type'] === 'expression') {
|
||||
$class_value = "Jqhtml_Component_Init ' . {$html_attrs['class']['value']} . '";
|
||||
$class_value = "Component_Init ' . {$html_attrs['class']['value']} . '";
|
||||
} else {
|
||||
$class_value = 'Jqhtml_Component_Init ' . $html_attrs['class']['value'];
|
||||
$class_value = 'Component_Init ' . $html_attrs['class']['value'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +229,13 @@ class JqhtmlBladeCompiler
|
||||
}
|
||||
|
||||
// Build HTML attributes string
|
||||
// Handle class attribute specially to merge with Jqhtml_Component_Init
|
||||
$class_value = 'Jqhtml_Component_Init';
|
||||
// Handle class attribute specially to merge with Component_Init
|
||||
$class_value = 'Component_Init';
|
||||
if (isset($html_attrs['class'])) {
|
||||
if ($html_attrs['class']['type'] === 'expression') {
|
||||
$class_value = "Jqhtml_Component_Init ' . {$html_attrs['class']['value']} . '";
|
||||
$class_value = "Component_Init ' . {$html_attrs['class']['value']} . '";
|
||||
} else {
|
||||
$class_value = 'Jqhtml_Component_Init ' . $html_attrs['class']['value'];
|
||||
$class_value = 'Component_Init ' . $html_attrs['class']['value'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* JQHTML Integration - Automatic component registration and binding
|
||||
*
|
||||
* This module automatically:
|
||||
* 1. Registers component classes that extend Jqhtml_Component
|
||||
* 1. Registers component classes that extend Component
|
||||
* 2. Binds templates to component classes when names match
|
||||
* 3. Enables $(selector).component("Component_Name") syntax
|
||||
*/
|
||||
@@ -13,13 +13,54 @@ class Jqhtml_Integration {
|
||||
* of framework init.
|
||||
*/
|
||||
static _on_framework_modules_define() {
|
||||
let jqhtml_components = Manifest.get_extending('Jqhtml_Component');
|
||||
let jqhtml_components = Manifest.get_extending('Component');
|
||||
|
||||
console_debug('JQHTML_INIT', 'Registering ' + jqhtml_components.length + ' Jqhtml Components');
|
||||
console_debug('JQHTML_INIT', 'Registering ' + jqhtml_components.length + ' Components');
|
||||
|
||||
for (let component of jqhtml_components) {
|
||||
jqhtml.register_component(component.class_name, component.class_object);
|
||||
}
|
||||
|
||||
// Assign unique cache IDs to all static methods for component caching
|
||||
// This enables JQHTML to generate deterministic cache keys when functions
|
||||
// are passed as component arguments (e.g., DataGrid data_source functions)
|
||||
const all_classes = Manifest.get_all_classes();
|
||||
let methods_tagged = 0;
|
||||
|
||||
for (const class_info of all_classes) {
|
||||
const class_object = class_info.class_object;
|
||||
const class_name = class_info.class_name;
|
||||
|
||||
// Get all property names from the class object (static methods/properties)
|
||||
const property_names = Object.getOwnPropertyNames(class_object);
|
||||
|
||||
for (const property_name of property_names) {
|
||||
// Skip built-in properties and non-functions
|
||||
if (property_name === 'length' ||
|
||||
property_name === 'name' ||
|
||||
property_name === 'prototype') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const property_value = class_object[property_name];
|
||||
|
||||
// Only tag functions (static methods)
|
||||
if (typeof property_value === 'function') {
|
||||
// Assign unique cache ID: "ClassName.methodName"
|
||||
property_value._jqhtml_cache_id = `${class_name}.${property_name}`;
|
||||
methods_tagged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console_debug('JQHTML_INIT', `Tagged ${methods_tagged} static methods with _jqhtml_cache_id`);
|
||||
|
||||
// Set the cache key for jqhtml to the application key to enable component caching in local storage.
|
||||
// Modifying the application or the user logging out modifies the scope_key and will invalidate jqhtml cache.
|
||||
jqhtml.set_cache_key(Rsx.scope_key());
|
||||
|
||||
// set this to true if we desire jqhtml verbose output (probably not necessary for end developers)
|
||||
window.jqhtml.debug.verbose = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +72,7 @@ class Jqhtml_Integration {
|
||||
static _on_framework_modules_init($scope) {
|
||||
const is_top_level = !$scope;
|
||||
const promises = [];
|
||||
const components_needing_init = ($scope || $('body')).find('.Jqhtml_Component_Init');
|
||||
const components_needing_init = ($scope || $('body')).find('.Component_Init');
|
||||
if (components_needing_init.length > 0) {
|
||||
console_debug('JQHTML_INIT', `Initializing ${components_needing_init.length} DOM components`);
|
||||
}
|
||||
@@ -45,10 +86,10 @@ class Jqhtml_Integration {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any parent has Jqhtml_Component_Init class - skip nested components
|
||||
// Check if any parent has Component_Init class - skip nested components
|
||||
let parent = $element[0].parentElement;
|
||||
while (parent) {
|
||||
if (parent.classList.contains('Jqhtml_Component_Init')) {
|
||||
if (parent.classList.contains('Component_Init')) {
|
||||
return; // Skip this element, it's nested
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
@@ -97,14 +138,14 @@ class Jqhtml_Integration {
|
||||
$element.empty();
|
||||
|
||||
// Remove the init class before instantiation to prevent re-initialization
|
||||
$element.removeClass('Jqhtml_Component_Init');
|
||||
$element.removeClass('Component_Init');
|
||||
|
||||
// Create promise for this component's initialization
|
||||
const component_promise = new Promise((resolve) => {
|
||||
// Use jQuery component plugin to create the component
|
||||
// Plugin handles element internally, just pass args
|
||||
// Get the updated $element from
|
||||
let component = $element.component(component_name, component_args_filtered);
|
||||
let component = $element.component(component_name, component_args_filtered).component();
|
||||
|
||||
component.on('render', function () {
|
||||
// Recursively collect promises from nested components
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.Jqhtml_Component_Init {
|
||||
.Component_Init {
|
||||
display:none;
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ class Jqhtml_ManifestSupport extends ManifestSupport_Abstract
|
||||
|
||||
$components = [];
|
||||
|
||||
// Build map of component_name => js_file for classes extending Jqhtml_Component
|
||||
// Build map of component_name => js_file for classes extending Component
|
||||
$js_classes = [];
|
||||
try {
|
||||
$extending_components = Manifest::js_get_extending('Jqhtml_Component');
|
||||
$extending_components = Manifest::js_get_extending('Component');
|
||||
foreach ($extending_components as $component_info) {
|
||||
if (isset($component_info['class']) && isset($component_info['file'])) {
|
||||
$js_classes[$component_info['class']] = $component_info['file'];
|
||||
|
||||
@@ -29,7 +29,7 @@ class Scss_ManifestModule extends ManifestModule_Abstract
|
||||
* Process a SCSS/CSS file and extract metadata
|
||||
*
|
||||
* For SCSS files, also detects if the file has a single top-level class selector
|
||||
* that matches a Blade view ID, JavaScript class extending Jqhtml_Component,
|
||||
* that matches a Blade view ID, JavaScript class extending Component,
|
||||
* or jqhtml template ID in the manifest.
|
||||
*/
|
||||
public function process(string $file_path, array $metadata): array
|
||||
@@ -213,7 +213,7 @@ class Scss_ManifestModule extends ManifestModule_Abstract
|
||||
*
|
||||
* The SCSS file gets an 'id' if:
|
||||
* 1. All rules are contained within a single top-level class selector
|
||||
* 2. The class name matches a Blade view ID, JS class extending Jqhtml_Component, or jqhtml template
|
||||
* 2. The class name matches a Blade view ID, JS class extending Component, or jqhtml template
|
||||
* 3. No other SCSS file already has this ID
|
||||
*/
|
||||
protected function detect_scss_id(string $clean_content, array &$metadata): void
|
||||
@@ -273,10 +273,10 @@ class Scss_ManifestModule extends ManifestModule_Abstract
|
||||
$found_match = true;
|
||||
}
|
||||
|
||||
// Check for JavaScript class extending Jqhtml_Component
|
||||
// Check for JavaScript class extending Component
|
||||
if (isset($file_data['extension']) && $file_data['extension'] === 'js' &&
|
||||
isset($file_data['class']) && $file_data['class'] === $class_name &&
|
||||
isset($file_data['extends']) && $file_data['extends'] === 'Jqhtml_Component') {
|
||||
isset($file_data['extends']) && $file_data['extends'] === 'Component') {
|
||||
$found_match = true;
|
||||
}
|
||||
}
|
||||
|
||||
360
app/RSpade/Lib/Flash/CLAUDE.md
Executable file
360
app/RSpade/Lib/Flash/CLAUDE.md
Executable file
@@ -0,0 +1,360 @@
|
||||
# Flash Alert System
|
||||
|
||||
The Flash system provides server-to-client messaging that persists across redirects and Ajax calls, with client-side queue persistence for seamless navigation.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Data Flow:**
|
||||
1. PHP code creates flash message → stored in database with type_id and session_id
|
||||
2. Message expires after 1 minute if not rendered
|
||||
3. On page render OR Ajax response → messages retrieved and deleted from database
|
||||
4. Client receives messages via `window.rsxapp.flash_alerts` or `response.flash_alerts`
|
||||
5. `Server_Side_Flash.js` processes messages → calls `Flash_Alert.js` display methods
|
||||
6. Client queue persisted to sessionStorage (per-tab) → survives page navigations
|
||||
7. On page load → restore queue from sessionStorage (messages < 20 seconds old)
|
||||
|
||||
## PHP Side - Creating Flash Messages
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```php
|
||||
use App\RSpade\Lib\Flash\Flash_Alert;
|
||||
|
||||
// In controllers
|
||||
Flash_Alert::success('Account created successfully!');
|
||||
Flash_Alert::error('Invalid email address');
|
||||
Flash_Alert::info('Your session will expire in 5 minutes');
|
||||
Flash_Alert::warning('This action cannot be undone');
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
**Storage:**
|
||||
- Messages stored in `flash_alerts` table with `type_id` (enum), `message`, and `session_id`
|
||||
- Uses integer enum: 1=success, 2=error, 3=info, 4=warning (see Flash_Alert_Model)
|
||||
- Linked to session via `Session::get_session_id()`
|
||||
- No session = messages silently not created
|
||||
|
||||
**Retrieval:**
|
||||
- `Flash_Alert::get_pending_messages()` retrieves all pending messages for current session
|
||||
- Automatically deletes expired messages (> 1 minute old) before retrieval
|
||||
- Automatically deletes retrieved messages (one-time display)
|
||||
- Returns array: `[['type' => 'success', 'message' => '...'], ...]`
|
||||
|
||||
**Automatic Integration:**
|
||||
- Page rendering: Added to `window.rsxapp.flash_alerts` in bundle render (Rsx_Bundle_Abstract.php:290)
|
||||
- Ajax responses: Added to `response.flash_alerts` in both success and error responses (Ajax.php:347, 447)
|
||||
|
||||
## JavaScript Side - Displaying Flash Messages
|
||||
|
||||
### Client Components
|
||||
|
||||
**Flash_Alert.js** (`/system/app/RSpade/Lib/Flash/Flash_Alert.js`):
|
||||
- Client-side display component with queue system, auto-dismiss, animations
|
||||
- Methods: `Flash_Alert.success()`, `.error()`, `.info()`, `.warning()`
|
||||
- Features: 2.5s queue spacing, auto-dismiss (4s success, 6s others), click-to-dismiss
|
||||
- Queue persistence: Saves state to sessionStorage on queue changes, restores on page load
|
||||
- Stale message filtering: Only restores messages < 20 seconds old
|
||||
- Styling: Bootstrap alert classes with icons
|
||||
|
||||
**Server_Side_Flash.js** (`/system/app/RSpade/Lib/Flash/Server_Side_Flash.js`):
|
||||
- Bridge between server data and Flash_Alert display
|
||||
- Processes `flash_alerts` arrays from server
|
||||
- Restores persisted queue state from sessionStorage on framework init
|
||||
- Called automatically on page load (framework init hook)
|
||||
- Called automatically on Ajax responses (Ajax.js:190)
|
||||
|
||||
### How Client Processing Works
|
||||
|
||||
**Page Load:**
|
||||
```javascript
|
||||
// Automatic via _on_framework_core_init() hook
|
||||
// 1. Restore persisted queue from sessionStorage (only fresh messages)
|
||||
Flash_Alert._restore_queue_state();
|
||||
|
||||
// 2. Process new server messages
|
||||
if (window.rsxapp && window.rsxapp.flash_alerts) {
|
||||
Server_Side_Flash.process(window.rsxapp.flash_alerts);
|
||||
}
|
||||
```
|
||||
|
||||
**Ajax Responses:**
|
||||
```javascript
|
||||
// Automatic in Ajax.js success handler
|
||||
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
|
||||
Server_Side_Flash.process(response.flash_alerts);
|
||||
}
|
||||
```
|
||||
|
||||
**Processing Logic:**
|
||||
```javascript
|
||||
Server_Side_Flash.process(flash_alerts) {
|
||||
flash_alerts.forEach(alert => {
|
||||
const method = alert.type; // 'success', 'error', 'info', 'warning'
|
||||
Flash_Alert[method](alert.message); // Calls Flash_Alert.success(), etc.
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
**Table:** `flash_alerts`
|
||||
|
||||
```sql
|
||||
CREATE TABLE flash_alerts (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id BIGINT NOT NULL,
|
||||
type_id BIGINT NOT NULL, -- 1=success, 2=error, 3=info, 4=warning
|
||||
message LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT,
|
||||
updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
INDEX idx_session_id (session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
**Model:** `Flash_Alert_Model` with type_id enum definition
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
**Post-Redirect Flash:**
|
||||
```php
|
||||
public static function create_account(Request $request, array $params = []) {
|
||||
$user = new User_Model();
|
||||
$user->email = $request->input('email');
|
||||
$user->save();
|
||||
|
||||
Flash_Alert::success('Account created successfully!');
|
||||
return redirect(Rsx::Route('Dashboard_Controller'));
|
||||
}
|
||||
```
|
||||
|
||||
**Ajax Error Handling:**
|
||||
```php
|
||||
#[Ajax_Endpoint]
|
||||
public static function save_settings(Request $request, array $params = []) {
|
||||
if (!$request->input('email')) {
|
||||
Flash_Alert::error('Email is required');
|
||||
return response_form_error('Validation failed', ['email' => 'Required']);
|
||||
}
|
||||
|
||||
// Save settings...
|
||||
Flash_Alert::success('Settings saved!');
|
||||
return ['saved' => true];
|
||||
}
|
||||
```
|
||||
|
||||
**Multi-Step Workflows:**
|
||||
```php
|
||||
// Step 1
|
||||
Flash_Alert::info('Please verify your email before continuing');
|
||||
return redirect(Rsx::Route('Verify_Email_Controller'));
|
||||
|
||||
// Step 2 (after email verified)
|
||||
Flash_Alert::success('Email verified! Your account is now active');
|
||||
return redirect(Rsx::Route('Dashboard_Controller'));
|
||||
```
|
||||
|
||||
## Migration from Old System
|
||||
|
||||
**Old (removed):**
|
||||
```php
|
||||
Rsx::flash_success('Message');
|
||||
Rsx::flash_error('Message');
|
||||
Rsx::render_flash_alerts(); // In blade views
|
||||
```
|
||||
|
||||
**New:**
|
||||
```php
|
||||
use App\RSpade\Lib\Flash\Flash_Alert;
|
||||
|
||||
Flash_Alert::success('Message');
|
||||
Flash_Alert::error('Message');
|
||||
// No render call needed - automatic via window.rsxapp
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
**Why 1-minute expiration?**
|
||||
- Prevents stale messages from appearing hours/days later
|
||||
- Covers normal redirect timing (< 1 second)
|
||||
- Covers slow network conditions
|
||||
- Aggressive cleanup keeps database small
|
||||
|
||||
**Why delete on retrieval?**
|
||||
- One-time display semantics (flash = temporary)
|
||||
- Prevents duplicate display on page refresh
|
||||
- Simpler than tracking "displayed" status
|
||||
|
||||
**Why session-based?**
|
||||
- Links messages to specific user session
|
||||
- Automatic cleanup when session expires
|
||||
- No messages shown without session (no logged-out flash)
|
||||
|
||||
**Why both page render AND Ajax integration?**
|
||||
- Page render: Handles redirects (Flash_Alert::success() → redirect → display)
|
||||
- Ajax: Handles same-page updates (Flash_Alert::success() → Ajax response → display)
|
||||
- Unified API works everywhere
|
||||
|
||||
**Why sessionStorage persistence?**
|
||||
- Handles Ajax + immediate redirect scenario (message queued, then page navigates)
|
||||
- Per-tab isolation (messages don't leak across browser tabs)
|
||||
- Messages survive page navigation (up to 20 seconds)
|
||||
- Automatic cleanup (stale messages filtered out, removed on dismiss)
|
||||
- No server-side complexity (client handles queue restoration)
|
||||
|
||||
## Queue Persistence System
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
**Two-queue design:**
|
||||
- **Working queue** (`_queue`): Controls display timing and spacing (2.5s between alerts)
|
||||
- **Persistence queue** (`_persistence_queue`): Saved to sessionStorage, survives navigation
|
||||
|
||||
**Why two queues?**
|
||||
- Working queue: Messages removed when displayed → prevents duplicate displays
|
||||
- Persistence queue: Messages removed when fadeout starts → maintains state across navigation
|
||||
- Separation ensures proper timing while enabling seamless cross-page experience
|
||||
|
||||
### Storage Mechanism
|
||||
|
||||
**Storage type:** sessionStorage (per-tab, survives navigation, cleared on tab close)
|
||||
|
||||
**Storage key:** `rsx_flash_queue`
|
||||
|
||||
**Stored data structure:**
|
||||
```javascript
|
||||
{
|
||||
last_updated: 1234567890, // Timestamp of last save
|
||||
messages: [
|
||||
{
|
||||
message: "Success!",
|
||||
level: "success",
|
||||
timeout: null,
|
||||
position: "top",
|
||||
queued_at: 1234567890,
|
||||
fade_in_complete: false, // Set true after fade-in animation
|
||||
fadeout_start_time: null // Timestamp when fadeout should begin
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Message Lifecycle & State Tracking
|
||||
|
||||
**Lifecycle stages:**
|
||||
1. **Queued**: Added to both queues, saved to sessionStorage
|
||||
2. **Displaying** (fade-in): 400ms fade-in animation
|
||||
3. **Fade-in complete**: `fade_in_complete = true`, `fadeout_start_time` calculated and saved
|
||||
4. **Visible**: Display duration (4s success, 6s others)
|
||||
5. **Fadeout**: 1s opacity fade + 250ms slide up, removed from persistence queue
|
||||
6. **Removed**: Element removed from DOM
|
||||
|
||||
**State tracking:**
|
||||
- `fade_in_complete`: Marks when fade-in animation completes
|
||||
- `fadeout_start_time`: Absolute timestamp when fadeout should begin
|
||||
- `last_updated`: Queue-level timestamp for staleness check
|
||||
|
||||
**Why track timing state?**
|
||||
- Enables SPA-like experience: alerts maintain consistent timing across page navigations
|
||||
- Prevents timing restarts: navigating mid-alert doesn't reset the display duration
|
||||
- Allows immediate display: alerts that completed fade-in on previous page show instantly on next page
|
||||
|
||||
### When State Changes
|
||||
|
||||
**State saved to sessionStorage:**
|
||||
- New message queued (`Flash_Alert._show()`)
|
||||
- Fade-in completes (`_mark_fade_in_complete()`)
|
||||
- Fadeout scheduled (`_set_fadeout_start_time()`)
|
||||
- Message removed from persistence queue (`_remove_from_persistence_queue()`)
|
||||
|
||||
**State restored from sessionStorage:**
|
||||
- On page load via `Server_Side_Flash._on_framework_core_init()` hook
|
||||
- Before processing new server messages
|
||||
- Applies staleness filter and fadeout time filter
|
||||
|
||||
**State cleared:**
|
||||
- Fadeout begins for individual messages (removed from persistence queue)
|
||||
- Entire queue becomes stale (last_updated > 20 seconds)
|
||||
- All messages dismissed/removed (storage key deleted)
|
||||
|
||||
### Staleness & Filtering
|
||||
|
||||
**20-second rule:**
|
||||
- If `last_updated` timestamp is > 20 seconds old, entire queue is discarded
|
||||
- Prevents ancient messages from appearing after long delays
|
||||
- Based on queue-level timestamp, not individual message age
|
||||
|
||||
**Fadeout time filter:**
|
||||
- On restore, messages past their `fadeout_start_time` are discarded
|
||||
- Prevents "zombie" messages that should have already faded out
|
||||
- Only applies to messages with scheduled fadeouts (fade-in complete)
|
||||
|
||||
### Navigation Behavior
|
||||
|
||||
**On page navigation (beforeunload):**
|
||||
- Alerts still fading in: Hidden immediately (will restore and continue fade-in on next page)
|
||||
- Alerts fully visible: Remain visible during navigation (with scheduled fadeout)
|
||||
- Persistence queue: Unchanged (all state preserved to sessionStorage)
|
||||
|
||||
**On page load (restoration):**
|
||||
- Messages with `fade_in_complete = true`:
|
||||
- Displayed immediately (no fade-in animation, no queue delay)
|
||||
- Honor original `fadeout_start_time` (not restarted)
|
||||
- Displayed outside normal queue (doesn't block queue processing)
|
||||
- Messages with `fade_in_complete = false`:
|
||||
- Added to working queue for normal processing
|
||||
- Display with 2.5s spacing and full fade-in animation
|
||||
- Calculate new `fadeout_start_time` after fade-in completes
|
||||
|
||||
### Common Scenarios
|
||||
|
||||
**Scenario 1: Ajax + Redirect (primary use case)**
|
||||
1. Ajax response includes flash message
|
||||
2. JavaScript queues message and saves to sessionStorage
|
||||
3. Page redirects immediately (before message displays)
|
||||
4. New page loads, restores queue from sessionStorage
|
||||
5. Message displays normally with 2.5s queue spacing
|
||||
6. Message removed from storage when fadeout begins
|
||||
|
||||
**Scenario 2: Mid-animation navigation**
|
||||
1. Alert is fading in (fade_in_complete = false)
|
||||
2. User navigates away (alert hidden by beforeunload)
|
||||
3. New page loads, message restored to working queue
|
||||
4. Alert displays with normal queue timing and fade-in animation
|
||||
5. After fade-in completes, fade_in_complete = true saved
|
||||
|
||||
**Scenario 3: Visible alert navigation (seamless SPA-like)**
|
||||
1. Alert fully visible (fade_in_complete = true, fadeout_start_time set)
|
||||
2. User navigates away (alert remains visible during navigation)
|
||||
3. New page loads, message has fade_in_complete = true
|
||||
4. Alert displayed immediately (no animation, no delay)
|
||||
5. Honors original fadeout_start_time (e.g., if 2s remaining, fades out in 2s)
|
||||
6. Creates seamless experience: alert appears to "survive" navigation
|
||||
|
||||
**Scenario 4: Multiple messages + navigation**
|
||||
1. Queue has 3 messages, first one displaying (fade_in_complete = true)
|
||||
2. User navigates away
|
||||
3. New page loads:
|
||||
- First message (fade_in_complete = true): Shows immediately
|
||||
- Second message (fade_in_complete = false): Added to queue, displays in 2.5s
|
||||
- Third message (fade_in_complete = false): Added to queue, displays in 5s
|
||||
4. Queue processing continues normally for remaining messages
|
||||
|
||||
## File Locations
|
||||
|
||||
**PHP:**
|
||||
- `/system/app/RSpade/Lib/Flash/Flash_Alert.php` - Main Flash class
|
||||
- `/system/app/RSpade/Lib/Flash/Flash_Alert_Model.php` - Database model with type_id enum
|
||||
- Integration in `/system/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php:290`
|
||||
- Integration in `/system/app/RSpade/Core/Ajax/Ajax.php:347, 447`
|
||||
|
||||
**JavaScript:**
|
||||
- `/system/app/RSpade/Lib/Flash/Flash_Alert.js` - Display component with persistence
|
||||
- `/system/app/RSpade/Lib/Flash/Flash_Alert.scss` - Styles
|
||||
- `/system/app/RSpade/Lib/Flash/Server_Side_Flash.js` - Server bridge
|
||||
- Integration in `/system/app/RSpade/Core/Js/Ajax.js:190`
|
||||
|
||||
**Database:**
|
||||
- Migration: `/system/database/migrations/2025_11_13_193740_modify_flash_alerts_table_for_type_based_system.php`
|
||||
589
app/RSpade/Lib/Flash/Flash_Alert.js
Executable file
589
app/RSpade/Lib/Flash/Flash_Alert.js
Executable file
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* Flash_Alert - Temporary alert notification system with queue persistence
|
||||
*
|
||||
* Displays dismissable alert messages that auto-fade after a timeout.
|
||||
* Messages are queued to prevent overwhelming the user with simultaneous alerts.
|
||||
* Queue state persists across page navigations using sessionStorage (per-tab).
|
||||
*
|
||||
* Usage:
|
||||
* Flash_Alert.success('Changes saved!')
|
||||
* Flash_Alert.error('Something went wrong')
|
||||
* Flash_Alert.info('FYI: New feature available')
|
||||
* Flash_Alert.warning('Are you sure about that?')
|
||||
*
|
||||
* Features:
|
||||
* - Queue system prevents alert spam (2.5s minimum between alerts)
|
||||
* - Auto-dismiss after timeout (success: 4s, others: 6s)
|
||||
* - Click to dismiss immediately
|
||||
* - Smooth fade in/out animations
|
||||
* - Bootstrap alert styling with icons
|
||||
* - Persistent queue across page navigations (sessionStorage, tab-specific)
|
||||
* - Stale message filtering (entire queue discarded if > 20 seconds old)
|
||||
* - SPA-like experience: messages maintain timing state across navigation
|
||||
*
|
||||
* Queue Architecture:
|
||||
* - Two-queue system: working queue (_queue) and persistence queue (_persistence_queue)
|
||||
* - Working queue: Messages removed when displayed (controls display timing/spacing)
|
||||
* - Persistence queue: Messages removed when fadeout starts (saved to sessionStorage)
|
||||
* - Prevents duplicate displays while maintaining cross-navigation state
|
||||
*
|
||||
* Persistence System:
|
||||
* - Queue state saved to sessionStorage when messages queued or state changes
|
||||
* - State restored on page load via Server_Side_Flash._on_framework_core_init()
|
||||
* - Entire queue discarded if last_updated timestamp > 20 seconds old
|
||||
* - Individual messages filtered: discard if past their fadeout_start_time
|
||||
* - Per-tab isolation (sessionStorage doesn't share across browser tabs)
|
||||
* - Handles Ajax response + redirect scenario (message survives navigation)
|
||||
*
|
||||
* Message Lifecycle & Timing:
|
||||
* - Queued → Display (fade-in 400ms) → Visible (4s success, 6s others) → Fadeout (1s) → Removed
|
||||
* - fade_in_complete flag: Set after fade-in animation completes
|
||||
* - fadeout_start_time: Calculated timestamp when fadeout should begin
|
||||
* - Both saved to sessionStorage to maintain timing consistency across navigation
|
||||
*
|
||||
* Navigation Behavior:
|
||||
* - beforeunload event: Hides alerts still fading in, keeps fully visible alerts (with scheduled fadeout)
|
||||
* - On restore: Messages with fade_in_complete=true show immediately (no fade-in, no queue delay)
|
||||
* - On restore: Messages with fade_in_complete=false added to queue (normal 2.5s spacing)
|
||||
* - Restored messages honor original fadeout_start_time (not restarted)
|
||||
* - Creates seamless SPA-like experience across full page navigations
|
||||
*
|
||||
* Common Scenarios:
|
||||
* 1. Ajax + Redirect: Flash message queued during Ajax, page redirects immediately
|
||||
* → Message saved to sessionStorage → Restored and displayed on new page
|
||||
* 2. Mid-animation navigation: Alert fading in when user navigates away
|
||||
* → Alert hidden during navigation → Restored on new page, continues fade-in
|
||||
* 3. Visible alert navigation: Alert fully visible when user navigates
|
||||
* → Alert stays visible → Appears immediately on new page, honors original fadeout timing
|
||||
*/
|
||||
class Flash_Alert {
|
||||
// Queue and state tracking
|
||||
static _queue = []; // Working queue - messages removed when displayed
|
||||
static _persistence_queue = []; // Persistence queue - messages removed when fadeout starts
|
||||
static _is_in_progress = false;
|
||||
static _last_alert_time = 0;
|
||||
static _container = null;
|
||||
|
||||
/**
|
||||
* Initialize the flash alert container
|
||||
* @private
|
||||
*/
|
||||
static _init() {
|
||||
if (this._container) return;
|
||||
|
||||
// Create floating alert container
|
||||
this._container = $('<div id="floating-alert-container" class="floating-alert-container"></div>');
|
||||
$('body').append(this._container);
|
||||
|
||||
// Register page navigation handler (only once)
|
||||
window.addEventListener('beforeunload', () => {
|
||||
console.log('[Flash_Alert] Page navigation detected - cleaning up');
|
||||
this._cleanup_for_navigation();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup alerts on page navigation
|
||||
* Hides alerts still fading in, leaves fully faded-in alerts visible with scheduled fadeout
|
||||
* @private
|
||||
*/
|
||||
static _cleanup_for_navigation() {
|
||||
console.log('[Flash_Alert] Cleaning up for navigation:', {
|
||||
working_queue_before: this._queue.length,
|
||||
persistence_queue: this._persistence_queue.length,
|
||||
});
|
||||
|
||||
if (this._container) {
|
||||
const now = Date.now();
|
||||
|
||||
// Process each visible alert
|
||||
this._container.find('.alert-wrapper').each((index, wrapper) => {
|
||||
const $wrapper = $(wrapper);
|
||||
const message_text = $wrapper.find('.alert').text().trim().replace(/×$/, '').trim();
|
||||
|
||||
// Find this message in persistence queue to check fade_in_complete
|
||||
const msg = this._persistence_queue.find(m => m.message === message_text);
|
||||
|
||||
if (msg && msg.fade_in_complete) { // @JS-DEFENSIVE-01-EXCEPTION - Array.find() returns undefined when no match is found
|
||||
// Alert has fully faded in - leave it visible
|
||||
console.log('[Flash_Alert] Leaving fully faded-in alert visible:', message_text);
|
||||
|
||||
// Schedule fadeout based on fadeout_start_time
|
||||
if (msg.fadeout_start_time) {
|
||||
const time_until_fadeout = Math.max(0, msg.fadeout_start_time - now);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('[Flash_Alert] Navigation fadeout starting for:', message_text);
|
||||
// Fade to transparent, then slide up
|
||||
$wrapper.animate({ opacity: 0 }, 1000, () => {
|
||||
$wrapper.slideUp(250, () => {
|
||||
$wrapper.remove();
|
||||
});
|
||||
});
|
||||
}, time_until_fadeout);
|
||||
|
||||
console.log('[Flash_Alert] Scheduled fadeout for visible alert:', {
|
||||
message: message_text,
|
||||
time_until_fadeout,
|
||||
fadeout_start_time: msg.fadeout_start_time
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Alert still fading in - remove it
|
||||
console.log('[Flash_Alert] Removing alert still fading in:', message_text);
|
||||
$wrapper.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear working queue (stop processing new alerts)
|
||||
this._queue = [];
|
||||
|
||||
// Stop processing
|
||||
this._is_in_progress = false;
|
||||
|
||||
// DO NOT touch _persistence_queue or sessionStorage
|
||||
// They will be restored on the next page load
|
||||
|
||||
console.log('[Flash_Alert] Cleanup complete - persistence queue preserved');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save persistence queue state to sessionStorage
|
||||
* @private
|
||||
*/
|
||||
static _save_queue_state() {
|
||||
const state = {
|
||||
last_updated: Date.now(),
|
||||
messages: this._persistence_queue.map((item) => ({
|
||||
message: item.message,
|
||||
level: item.level,
|
||||
timeout: item.timeout,
|
||||
position: item.position,
|
||||
queued_at: item.queued_at,
|
||||
fade_in_complete: item.fade_in_complete || false,
|
||||
fadeout_start_time: item.fadeout_start_time || null,
|
||||
})),
|
||||
};
|
||||
console.log('[Flash_Alert] Saving persistence queue to sessionStorage:', {
|
||||
message_count: state.messages.length,
|
||||
last_updated: state.last_updated,
|
||||
messages: state.messages,
|
||||
});
|
||||
Rsx_Storage.session_set('rsx_flash_queue', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load queue state from sessionStorage
|
||||
* Discards entire queue if last_updated timestamp is > 20 seconds old
|
||||
* @private
|
||||
*/
|
||||
static _restore_queue_state() {
|
||||
const state = Rsx_Storage.session_get('rsx_flash_queue');
|
||||
console.log('[Flash_Alert] Attempting to restore queue state from sessionStorage:', {
|
||||
has_stored_data: !!state,
|
||||
stored_data: state,
|
||||
});
|
||||
|
||||
if (!state) {
|
||||
console.log('[Flash_Alert] No stored queue data found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const MAX_AGE = 20000; // 20 seconds in milliseconds
|
||||
|
||||
console.log('[Flash_Alert] Parsed stored state:', {
|
||||
message_count: state.messages.length,
|
||||
last_updated: state.last_updated,
|
||||
messages: state.messages,
|
||||
current_time: now,
|
||||
});
|
||||
|
||||
// Check if the stored queue is > 20 seconds old
|
||||
// If so, throw out the entire queue
|
||||
const queue_age = now - state.last_updated;
|
||||
if (queue_age > MAX_AGE) {
|
||||
console.log('[Flash_Alert] Stored queue is too old, discarding entire queue:', {
|
||||
queue_age_ms: queue_age,
|
||||
max_age_ms: MAX_AGE,
|
||||
last_updated: state.last_updated,
|
||||
});
|
||||
Rsx_Storage.session_remove('rsx_flash_queue');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter messages: remove those past their fadeout time
|
||||
const valid_messages = state.messages.filter((msg) => {
|
||||
// If fadeout was scheduled and already passed, discard message
|
||||
if (msg.fadeout_start_time && now >= msg.fadeout_start_time) {
|
||||
console.log('[Flash_Alert] Discarding message past fadeout time:', {
|
||||
message: msg.message,
|
||||
fadeout_start_time: msg.fadeout_start_time,
|
||||
current_time: now,
|
||||
time_past_fadeout: now - msg.fadeout_start_time,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log('[Flash_Alert] Valid messages to restore:', {
|
||||
count: valid_messages.length,
|
||||
messages: valid_messages,
|
||||
});
|
||||
|
||||
// Separate messages into two groups: fade-in complete vs not
|
||||
const messages_to_show_immediately = [];
|
||||
const messages_to_queue = [];
|
||||
|
||||
valid_messages.forEach((msg) => {
|
||||
const message_data = {
|
||||
message: msg.message,
|
||||
level: msg.level,
|
||||
timeout: msg.timeout,
|
||||
position: msg.position,
|
||||
queued_at: msg.queued_at,
|
||||
fade_in_complete: msg.fade_in_complete || false,
|
||||
fadeout_start_time: msg.fadeout_start_time || null,
|
||||
};
|
||||
|
||||
if (msg.fade_in_complete) {
|
||||
// Already completed fade-in - show immediately
|
||||
messages_to_show_immediately.push(message_data);
|
||||
} else {
|
||||
// Still needs fade-in - add to queue
|
||||
messages_to_queue.push(message_data);
|
||||
}
|
||||
|
||||
// Add all to persistence queue
|
||||
this._persistence_queue.push(message_data);
|
||||
});
|
||||
|
||||
console.log('[Flash_Alert] Messages after restoration:', {
|
||||
to_show_immediately: messages_to_show_immediately.length,
|
||||
to_queue: messages_to_queue.length,
|
||||
persistence_queue_length: this._persistence_queue.length,
|
||||
});
|
||||
|
||||
// Re-save persistence queue to sessionStorage
|
||||
this._save_queue_state();
|
||||
|
||||
// Ensure container is initialized before displaying
|
||||
this._init();
|
||||
|
||||
// Display fade-in complete messages immediately (no delay, no fade-in)
|
||||
// These are displayed outside the normal queue, so don't affect _is_in_progress
|
||||
if (messages_to_show_immediately.length > 0) {
|
||||
console.log('[Flash_Alert] Displaying fade-in complete messages immediately');
|
||||
messages_to_show_immediately.forEach((msg) => {
|
||||
this._display_alert(msg, true); // true = immediate display, don't block queue
|
||||
});
|
||||
}
|
||||
|
||||
// Add remaining messages to working queue for normal processing
|
||||
messages_to_queue.forEach((msg) => {
|
||||
this._queue.push(msg);
|
||||
});
|
||||
|
||||
// Start queue processing for messages that still need fade-in
|
||||
if (this._queue.length > 0) {
|
||||
console.log('[Flash_Alert] Starting queue processing for messages needing fade-in');
|
||||
if (!this._is_in_progress) {
|
||||
this._process_queue();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Flash_Alert] Failed to restore flash queue state:', e);
|
||||
sessionStorage.removeItem('rsx_flash_queue');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message from persistence queue when fadeout starts
|
||||
* @private
|
||||
*/
|
||||
static _remove_from_persistence_queue(message, level) {
|
||||
const original_count = this._persistence_queue.length;
|
||||
this._persistence_queue = this._persistence_queue.filter((msg) => {
|
||||
return !(msg.level === level && msg.message === message);
|
||||
});
|
||||
|
||||
console.log('[Flash_Alert] Removing message from persistence queue:', {
|
||||
message: message,
|
||||
level: level,
|
||||
original_count: original_count,
|
||||
remaining_count: this._persistence_queue.length,
|
||||
});
|
||||
|
||||
// Save updated persistence queue to sessionStorage
|
||||
this._save_queue_state();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark fade-in complete for a message in persistence queue
|
||||
* @private
|
||||
*/
|
||||
static _mark_fade_in_complete(message, level) {
|
||||
const msg = this._persistence_queue.find((m) => m.level === level && m.message === message);
|
||||
if (msg) {
|
||||
msg.fade_in_complete = true;
|
||||
console.log('[Flash_Alert] Marked fade-in complete:', { message, level });
|
||||
this._save_queue_state();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fadeout start time for a message in persistence queue
|
||||
* @private
|
||||
*/
|
||||
static _set_fadeout_start_time(message, level, fadeout_start_time) {
|
||||
const msg = this._persistence_queue.find((m) => m.level === level && m.message === message);
|
||||
if (msg) {
|
||||
msg.fadeout_start_time = fadeout_start_time;
|
||||
console.log('[Flash_Alert] Set fadeout start time:', { message, level, fadeout_start_time });
|
||||
this._save_queue_state();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success alert
|
||||
* @param {string} message - The message to display
|
||||
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 4000ms)
|
||||
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
|
||||
*/
|
||||
static success(message, timeout = null, position = 'top') {
|
||||
this._show(message, 'success', timeout, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error alert
|
||||
* @param {string} message - The message to display
|
||||
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
|
||||
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
|
||||
*/
|
||||
static error(message, timeout = null, position = 'top') {
|
||||
this._show(message, 'danger', timeout, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an info alert
|
||||
* @param {string} message - The message to display
|
||||
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
|
||||
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
|
||||
*/
|
||||
static info(message, timeout = null, position = 'top') {
|
||||
this._show(message, 'info', timeout, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a warning alert
|
||||
* @param {string} message - The message to display
|
||||
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
|
||||
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
|
||||
*/
|
||||
static warning(message, timeout = null, position = 'top') {
|
||||
this._show(message, 'warning', timeout, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an alert with custom level
|
||||
* @param {string} message - The message to display
|
||||
* @param {string} level - Alert level: 'success', 'danger', 'info', 'warning'
|
||||
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = use default)
|
||||
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
|
||||
* @private
|
||||
*/
|
||||
static _show(message, level = 'info', timeout = null, position = 'top') {
|
||||
this._init();
|
||||
|
||||
// Add to BOTH queues with timestamp
|
||||
const message_data = {
|
||||
message,
|
||||
level,
|
||||
timeout,
|
||||
position,
|
||||
queued_at: Date.now(),
|
||||
};
|
||||
|
||||
this._queue.push(message_data);
|
||||
this._persistence_queue.push(message_data);
|
||||
|
||||
// Save persistence queue to sessionStorage
|
||||
this._save_queue_state();
|
||||
|
||||
// Process queue if not already processing
|
||||
if (!this._is_in_progress) {
|
||||
this._process_queue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the next alert in the queue
|
||||
* @private
|
||||
*/
|
||||
static _process_queue() {
|
||||
const now = Date.now();
|
||||
|
||||
console.log('[Flash_Alert] Processing queue:', {
|
||||
working_queue_length: this._queue.length,
|
||||
persistence_queue_length: this._persistence_queue.length,
|
||||
is_in_progress: this._is_in_progress,
|
||||
time_since_last: now - this._last_alert_time,
|
||||
});
|
||||
|
||||
// Display next alert if enough time has passed (2.5s minimum between alerts)
|
||||
if (now - this._last_alert_time >= 2500 && this._queue.length > 0) {
|
||||
// Remove from working queue (shift) but stays in persistence queue until fadeout
|
||||
const alert_data = this._queue.shift();
|
||||
console.log('[Flash_Alert] Displaying alert from queue:', alert_data);
|
||||
console.log('[Flash_Alert] Working queue after shift:', this._queue.length);
|
||||
this._display_alert(alert_data);
|
||||
this._last_alert_time = now;
|
||||
}
|
||||
|
||||
// Schedule next queue check if more alerts pending
|
||||
if (this._queue.length > 0) {
|
||||
console.log('[Flash_Alert] Scheduling next queue check in 2.5s');
|
||||
setTimeout(() => this._process_queue(), 2500);
|
||||
} else {
|
||||
console.log('[Flash_Alert] Working queue empty, stopping processing');
|
||||
this._is_in_progress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single alert
|
||||
* @param {Object} alert_data - Alert configuration
|
||||
* @param {boolean} is_immediate - If true, don't set _is_in_progress (for restored alerts displayed outside queue)
|
||||
* @private
|
||||
*/
|
||||
static _display_alert({ message, level, timeout, position = 'top', fade_in_complete = false, fadeout_start_time = null }, is_immediate = false) {
|
||||
// Only set in_progress if this is a queued display (not an immediate restored alert)
|
||||
if (!is_immediate) {
|
||||
this._is_in_progress = true;
|
||||
}
|
||||
|
||||
console.log('[Flash_Alert] Displaying alert:', {
|
||||
message,
|
||||
level,
|
||||
fade_in_complete,
|
||||
fadeout_start_time,
|
||||
current_time: Date.now(),
|
||||
});
|
||||
|
||||
// Check if an alert with the same message is already displayed
|
||||
let duplicate_found = false;
|
||||
this._container.find('.alert-wrapper').each(function () {
|
||||
const existing_text = $(this).find('.alert').text().trim();
|
||||
// Remove the close button text (×) for comparison
|
||||
const existing_message = existing_text.replace(/×$/, '').trim();
|
||||
if (existing_message === message) {
|
||||
duplicate_found = true;
|
||||
return false; // Break loop
|
||||
}
|
||||
});
|
||||
|
||||
// Skip displaying if duplicate found
|
||||
if (duplicate_found) {
|
||||
console.log('[Flash_Alert] Duplicate found, skipping display');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create alert element
|
||||
const $alert = $(`<div class="alert alert-${level} alert-dismissible fade show" role="alert">`);
|
||||
|
||||
// Add icon based on level
|
||||
if (level === 'danger') {
|
||||
$alert.append('<i class="bi bi-exclamation-circle-fill me-2"></i>');
|
||||
} else if (level === 'success') {
|
||||
$alert.append('<i class="bi bi-check-circle-fill me-2"></i>');
|
||||
} else if (level === 'info') {
|
||||
$alert.append('<i class="bi bi-info-circle-fill me-2"></i>');
|
||||
} else if (level === 'warning') {
|
||||
$alert.append('<i class="bi bi-exclamation-triangle-fill me-2"></i>');
|
||||
}
|
||||
|
||||
// Add close button
|
||||
const $close_button = $('<button type="button" class="btn-close" aria-label="Close"></button>');
|
||||
$alert.append($close_button).append(message);
|
||||
|
||||
// Wrap in container for animation
|
||||
const $alert_container = $('<div class="alert-wrapper">').append($alert);
|
||||
|
||||
// Add to floating container based on position
|
||||
if (position === 'top') {
|
||||
// Top position - append (newer alerts below older ones)
|
||||
this._container.css('justify-content', 'flex-start').append($alert_container);
|
||||
} else {
|
||||
// Bottom position - prepend (newer alerts above older ones)
|
||||
this._container.css('justify-content', 'flex-end').prepend($alert_container);
|
||||
}
|
||||
|
||||
// Fade in (skip if already completed fade-in on previous page)
|
||||
if (fade_in_complete) {
|
||||
console.log('[Flash_Alert] Skipping fade-in, showing immediately (restored from previous page)');
|
||||
$alert_container.show();
|
||||
} else {
|
||||
$alert_container.hide().fadeIn(400, () => {
|
||||
console.log('[Flash_Alert] Fade-in complete');
|
||||
// Mark fade-in complete in persistence queue
|
||||
this._mark_fade_in_complete(message, level);
|
||||
});
|
||||
}
|
||||
|
||||
// Close function - fade to transparent, then slide up
|
||||
const close_alert = (speed) => {
|
||||
$alert_container.animate({ opacity: 0 }, speed, () => {
|
||||
$alert_container.slideUp(250, () => {
|
||||
$alert_container.remove();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate when fadeout should start
|
||||
const now = Date.now();
|
||||
let time_until_fadeout;
|
||||
|
||||
if (fadeout_start_time) {
|
||||
// Honor existing fadeout schedule from restored state
|
||||
time_until_fadeout = Math.max(0, fadeout_start_time - now);
|
||||
console.log('[Flash_Alert] Using restored fadeout schedule:', {
|
||||
fadeout_start_time,
|
||||
now,
|
||||
time_until_fadeout,
|
||||
});
|
||||
} else {
|
||||
// New message - calculate fadeout time (4s for success, 6s for others)
|
||||
const display_duration = level === 'success' ? 4000 : 6000;
|
||||
time_until_fadeout = display_duration;
|
||||
const new_fadeout_start_time = now + display_duration;
|
||||
|
||||
console.log('[Flash_Alert] Scheduling new fadeout:', {
|
||||
display_duration,
|
||||
fadeout_start_time: new_fadeout_start_time,
|
||||
});
|
||||
|
||||
// Store fadeout start time in persistence queue
|
||||
this._set_fadeout_start_time(message, level, new_fadeout_start_time);
|
||||
}
|
||||
|
||||
// Schedule fadeout
|
||||
if (time_until_fadeout >= 0) {
|
||||
setTimeout(() => {
|
||||
console.log('[Flash_Alert] Fadeout starting for:', message);
|
||||
// Remove from persistence queue when fadeout starts
|
||||
this._remove_from_persistence_queue(message, level);
|
||||
close_alert(1000);
|
||||
}, time_until_fadeout);
|
||||
}
|
||||
|
||||
// Click anywhere on alert to dismiss
|
||||
$alert.click(() => {
|
||||
// Remove from persistence queue when manually dismissed
|
||||
this._remove_from_persistence_queue(message, level);
|
||||
close_alert(300);
|
||||
});
|
||||
}
|
||||
}
|
||||
145
app/RSpade/Lib/Flash/Flash_Alert.php
Executable file
145
app/RSpade/Lib/Flash/Flash_Alert.php
Executable file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Lib\Flash;
|
||||
|
||||
use App\RSpade\Core\Session\Session;
|
||||
use App\RSpade\Lib\Flash\Flash_Alert_Model;
|
||||
|
||||
/**
|
||||
* Flash_Alert - Server-side flash message system
|
||||
*
|
||||
* Provides a simple API for creating flash messages that will be delivered
|
||||
* to the client via page rendering or Ajax responses. Messages are stored
|
||||
* in the database and expire after 1 minute if not rendered.
|
||||
*
|
||||
* Usage:
|
||||
* Flash_Alert::success('Account created successfully!');
|
||||
* Flash_Alert::error('Invalid email address');
|
||||
* Flash_Alert::info('Your session will expire in 5 minutes');
|
||||
* Flash_Alert::warning('This action cannot be undone');
|
||||
*
|
||||
* Messages are automatically:
|
||||
* - Included in window.rsxapp.flash_alerts on page render
|
||||
* - Included in Ajax response.flash_alerts for Ajax calls
|
||||
* - Displayed via Flash_Alert JavaScript class on client
|
||||
* - Deleted after being retrieved for rendering
|
||||
* - Expired after 1 minute if not rendered
|
||||
*/
|
||||
class Flash_Alert
|
||||
{
|
||||
/**
|
||||
* Create a success flash message
|
||||
*
|
||||
* @param string $message The message to display
|
||||
* @return void
|
||||
*/
|
||||
public static function success(string $message): void
|
||||
{
|
||||
static::_create_message($message, Flash_Alert_Model::TYPE_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error flash message
|
||||
*
|
||||
* @param string $message The message to display
|
||||
* @return void
|
||||
*/
|
||||
public static function error(string $message): void
|
||||
{
|
||||
static::_create_message($message, Flash_Alert_Model::TYPE_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an info flash message
|
||||
*
|
||||
* @param string $message The message to display
|
||||
* @return void
|
||||
*/
|
||||
public static function info(string $message): void
|
||||
{
|
||||
static::_create_message($message, Flash_Alert_Model::TYPE_INFO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a warning flash message
|
||||
*
|
||||
* @param string $message The message to display
|
||||
* @return void
|
||||
*/
|
||||
public static function warning(string $message): void
|
||||
{
|
||||
static::_create_message($message, Flash_Alert_Model::TYPE_WARNING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending flash messages for current session
|
||||
*
|
||||
* Returns array of messages and deletes them from database.
|
||||
* Also deletes any messages older than 1 minute (expired).
|
||||
*
|
||||
* @return array Array of ['type' => 'success'|'error'|'info'|'warning', 'message' => '...']
|
||||
*/
|
||||
public static function get_pending_messages(): array
|
||||
{
|
||||
$session_id = Session::get_session_id();
|
||||
if ($session_id === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Delete expired messages (older than 1 minute)
|
||||
Flash_Alert_Model::where('session_id', $session_id)
|
||||
->where('created_at', '<', now()->subMinute())
|
||||
->delete();
|
||||
|
||||
// Get all pending flash alerts for this session
|
||||
$alerts = Flash_Alert_Model::where('session_id', $session_id)
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
|
||||
if ($alerts->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Delete the alerts now that we're retrieving them
|
||||
Flash_Alert_Model::where('session_id', $session_id)
|
||||
->delete();
|
||||
|
||||
// Convert to client format
|
||||
$messages = [];
|
||||
foreach ($alerts as $alert) {
|
||||
$messages[] = [
|
||||
'type' => $alert->get_type_string(),
|
||||
'message' => $alert->message,
|
||||
];
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flash message in the database
|
||||
*
|
||||
* @param string $message The message text
|
||||
* @param int $type_id The type ID (use Flash_Alert_Model constants)
|
||||
* @return void
|
||||
*/
|
||||
protected static function _create_message(string $message, int $type_id): void
|
||||
{
|
||||
$session_id = Session::get_session_id();
|
||||
if ($session_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flash_alert = new Flash_Alert_Model();
|
||||
$flash_alert->session_id = $session_id;
|
||||
$flash_alert->type_id = $type_id;
|
||||
$flash_alert->message = $message;
|
||||
$flash_alert->created_at = now();
|
||||
$flash_alert->save();
|
||||
}
|
||||
}
|
||||
63
app/RSpade/Lib/Flash/Flash_Alert.scss
Executable file
63
app/RSpade/Lib/Flash/Flash_Alert.scss
Executable file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Flash Alert Styles
|
||||
*
|
||||
* Styles for the floating alert notification system.
|
||||
* Alerts appear centered at the top (60px down) or bottom and stack vertically.
|
||||
*
|
||||
* @SCSS-ANIM-01-EXCEPTION: Hover lift effect acceptable for transient notifications
|
||||
*/
|
||||
|
||||
.floating-alert-container {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center; // Center alerts horizontally
|
||||
gap: 12px;
|
||||
max-width: 500px;
|
||||
pointer-events: none; // Allow clicks through container
|
||||
|
||||
.alert-wrapper {
|
||||
pointer-events: auto; // But allow clicks on individual alerts
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: auto; // Size to content, not container
|
||||
min-width: 300px; // Minimum width for alerts
|
||||
|
||||
.alert {
|
||||
margin-bottom: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// Icon spacing
|
||||
i {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Close button styling
|
||||
.btn-close {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile responsive adjustments
|
||||
@media (max-width: 768px) {
|
||||
.floating-alert-container {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
74
app/RSpade/Lib/Flash/Flash_Alert_Model.php
Executable file
74
app/RSpade/Lib/Flash/Flash_Alert_Model.php
Executable file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Lib\Flash;
|
||||
|
||||
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_
|
||||
* @property integer $id
|
||||
* @property integer $session_id
|
||||
* @property integer $type_id
|
||||
* @property string $message
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property integer $created_by
|
||||
* @property integer $updated_by
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @method static mixed type_id_enum()
|
||||
* @method static mixed type_id_enum_select()
|
||||
* @method static mixed type_id_enum_ids()
|
||||
* @property-read mixed $type_id_constant
|
||||
* @property-read mixed $type_id_label
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class Flash_Alert_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
/** __AUTO_GENERATED: */
|
||||
const TYPE_SUCCESS = 1;
|
||||
const TYPE_ERROR = 2;
|
||||
const TYPE_INFO = 3;
|
||||
const TYPE_WARNING = 4;
|
||||
/** __/AUTO_GENERATED */
|
||||
|
||||
// Enum constants (auto-generated by rsx:migrate:document_models)
|
||||
|
||||
public static $enums = [
|
||||
'type_id' => [
|
||||
1 => ['constant' => 'TYPE_SUCCESS', 'label' => 'Success'],
|
||||
2 => ['constant' => 'TYPE_ERROR', 'label' => 'Error'],
|
||||
3 => ['constant' => 'TYPE_INFO', 'label' => 'Info'],
|
||||
4 => ['constant' => 'TYPE_WARNING', 'label' => 'Warning'],
|
||||
],
|
||||
];
|
||||
|
||||
public static $rel = [];
|
||||
|
||||
protected $table = '_flash_alerts';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the type as a string for client consumption
|
||||
*
|
||||
* @return string 'success'|'error'|'info'|'warning'
|
||||
*/
|
||||
public function get_type_string(): string
|
||||
{
|
||||
$type_map = [
|
||||
self::TYPE_SUCCESS => 'success',
|
||||
self::TYPE_ERROR => 'error',
|
||||
self::TYPE_INFO => 'info',
|
||||
self::TYPE_WARNING => 'warning',
|
||||
];
|
||||
|
||||
return $type_map[$this->type_id] ?? 'info';
|
||||
}
|
||||
}
|
||||
62
app/RSpade/Lib/Flash/Server_Side_Flash.js
Executable file
62
app/RSpade/Lib/Flash/Server_Side_Flash.js
Executable file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Server_Side_Flash - Bridge between server-generated flash messages and Flash_Alert display
|
||||
*
|
||||
* Processes flash_alerts arrays from server (page renders and Ajax responses)
|
||||
* and displays them using the Flash_Alert component.
|
||||
*
|
||||
* Integration points:
|
||||
* - Page render: window.rsxapp.flash_alerts populated by bundle renderer
|
||||
* - Ajax responses: response.flash_alerts added to Ajax success/error responses
|
||||
*
|
||||
* Initialization:
|
||||
* - Automatically called on framework init hook
|
||||
* - Restores persisted queue state from sessionStorage before processing server messages
|
||||
*/
|
||||
class Server_Side_Flash {
|
||||
/**
|
||||
* Framework initialization hook
|
||||
* Restores queue state and processes server flash messages
|
||||
* @private
|
||||
*/
|
||||
static _on_framework_core_init() {
|
||||
console.log('[Server_Side_Flash] Framework init hook called');
|
||||
|
||||
// Restore any persisted queue state from sessionStorage first
|
||||
console.log('[Server_Side_Flash] Calling Flash_Alert._restore_queue_state()');
|
||||
Flash_Alert._restore_queue_state();
|
||||
|
||||
// Then process server-generated flash messages
|
||||
const has_server_messages = !!(window.rsxapp && window.rsxapp.flash_alerts);
|
||||
console.log('[Server_Side_Flash] Checking for server messages:', {
|
||||
has_rsxapp: !!window.rsxapp,
|
||||
has_flash_alerts: has_server_messages,
|
||||
flash_alerts: window.rsxapp?.flash_alerts
|
||||
});
|
||||
|
||||
if (has_server_messages) {
|
||||
console.log('[Server_Side_Flash] Processing server messages');
|
||||
Server_Side_Flash.process(window.rsxapp.flash_alerts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of flash alert messages
|
||||
* @param {Array} flash_alerts - Array of {type: string, message: string} objects
|
||||
*/
|
||||
static process(flash_alerts) {
|
||||
if (!Array.isArray(flash_alerts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
flash_alerts.forEach((alert) => {
|
||||
const method = alert.type; // 'success', 'error', 'info', 'warning'
|
||||
|
||||
// Call Flash_Alert method if it exists
|
||||
if (Flash_Alert[method] && typeof Flash_Alert[method] === 'function') {
|
||||
Flash_Alert[method](alert.message);
|
||||
} else {
|
||||
console.error('Unknown flash alert type:', alert.type);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
1
app/RSpade/Lib/Flash/refactor.list
Executable file
1
app/RSpade/Lib/Flash/refactor.list
Executable file
@@ -0,0 +1 @@
|
||||
[2025-11-14 01:30:33] rsx:refactor:rename_php_class Flash Flash_Alert
|
||||
135
app/RSpade/Services/Cleanup_Service.php
Executable file
135
app/RSpade/Services/Cleanup_Service.php
Executable file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Services;
|
||||
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
use App\RSpade\Core\Task\Task_Attribute;
|
||||
use App\RSpade\Core\Task\Schedule;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Cleanup Service
|
||||
*
|
||||
* System maintenance tasks that run on a schedule.
|
||||
* Handles cleanup of old data and temporary files.
|
||||
*/
|
||||
class Cleanup_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
/**
|
||||
* Clean up old completed/failed tasks
|
||||
*
|
||||
* Deletes task records older than the retention period (default: 30 days)
|
||||
* Keeps pending/running tasks regardless of age.
|
||||
*
|
||||
* Runs daily at 3 AM
|
||||
*/
|
||||
#[Task_Attribute('Clean up old completed and failed task records')]
|
||||
#[Schedule('0 3 * * *')]
|
||||
public static function cleanup_old_tasks(Task_Instance $task, array $params = []): array
|
||||
{
|
||||
$retention_days = config('rsx.tasks.task_retention_days', 30);
|
||||
$cutoff_date = now()->subDays($retention_days);
|
||||
|
||||
$task->info("Cleaning up tasks older than {$retention_days} days (before {$cutoff_date})");
|
||||
|
||||
// Delete old completed tasks
|
||||
$deleted_completed = DB::table('_tasks')
|
||||
->where('status', Task_Status::COMPLETED)
|
||||
->where('completed_at', '<', $cutoff_date)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$deleted_completed} completed task(s)");
|
||||
|
||||
// Delete old failed tasks
|
||||
$deleted_failed = DB::table('_tasks')
|
||||
->where('status', Task_Status::FAILED)
|
||||
->where('completed_at', '<', $cutoff_date)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$deleted_failed} failed task(s)");
|
||||
|
||||
$total_deleted = $deleted_completed + $deleted_failed;
|
||||
|
||||
return [
|
||||
'deleted_completed' => $deleted_completed,
|
||||
'deleted_failed' => $deleted_failed,
|
||||
'total_deleted' => $total_deleted,
|
||||
'retention_days' => $retention_days,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned task temporary directories
|
||||
*
|
||||
* Removes temp directories for tasks that no longer exist or are completed.
|
||||
* Checks the temp_expires_at timestamp for cleanup eligibility.
|
||||
*
|
||||
* Runs every hour
|
||||
*/
|
||||
#[Task_Attribute('Clean up orphaned task temporary directories')]
|
||||
#[Schedule('0 * * * *')]
|
||||
public static function cleanup_temp_directories(Task_Instance $task, array $params = []): array
|
||||
{
|
||||
$base_temp_dir = storage_path('rsx-tmp/tasks');
|
||||
|
||||
if (!is_dir($base_temp_dir)) {
|
||||
$task->info("Temp directory does not exist: {$base_temp_dir}");
|
||||
return ['directories_removed' => 0];
|
||||
}
|
||||
|
||||
$task->info("Scanning temp directory: {$base_temp_dir}");
|
||||
|
||||
$directories_removed = 0;
|
||||
$directories = File::directories($base_temp_dir);
|
||||
|
||||
foreach ($directories as $dir) {
|
||||
$dir_name = basename($dir);
|
||||
|
||||
// Check if it's an immediate task temp dir (these should be cleaned up by the task itself)
|
||||
if (str_starts_with($dir_name, 'immediate_')) {
|
||||
// Check if directory is old (more than 1 hour)
|
||||
$dir_time = filemtime($dir);
|
||||
if (time() - $dir_time > 3600) {
|
||||
$task->info("Removing old immediate temp directory: {$dir_name}");
|
||||
File::deleteDirectory($dir);
|
||||
$directories_removed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract task ID from directory name (format: task_123)
|
||||
if (preg_match('/^task_(\d+)$/', $dir_name, $matches)) {
|
||||
$task_id = (int) $matches[1];
|
||||
|
||||
// Check if task still exists
|
||||
$task_row = DB::table('_tasks')->where('id', $task_id)->first();
|
||||
|
||||
if (!$task_row) {
|
||||
// Task doesn't exist, remove directory
|
||||
$task->info("Removing orphaned temp directory for deleted task: {$dir_name}");
|
||||
File::deleteDirectory($dir);
|
||||
$directories_removed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if task is completed/failed
|
||||
if (in_array($task_row->status, [Task_Status::COMPLETED, Task_Status::FAILED])) {
|
||||
$task->info("Removing temp directory for completed/failed task: {$dir_name}");
|
||||
File::deleteDirectory($dir);
|
||||
$directories_removed++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$task->info("Removed {$directories_removed} temp director(ies)");
|
||||
|
||||
return [
|
||||
'directories_removed' => $directories_removed,
|
||||
'total_directories_scanned' => count($directories),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -473,6 +473,10 @@ function rsx_view($id, $data = [], $merge_data = [])
|
||||
// Handle different view locations
|
||||
if (str_starts_with($view_name, 'resources/views/')) {
|
||||
$view_name = substr($view_name, strlen('resources/views/'));
|
||||
} elseif (str_starts_with($view_name, 'app/RSpade/')) {
|
||||
// For framework views, use namespace format
|
||||
// The namespace 'rspade' is registered in Rsx_Framework_Provider
|
||||
$view_name = 'rspade::' . substr($view_name, strlen('app/RSpade/'));
|
||||
} elseif (str_starts_with($view_name, 'rsx/')) {
|
||||
// For RSX views, use namespace format
|
||||
// The namespace 'rsx' is registered in RsxServiceProvider
|
||||
@@ -1389,3 +1393,101 @@ function duration_to_human($seconds, $round_to_whole_value = false)
|
||||
return count($parts) > 1 ? $parts[0] . " and " . $parts[1] : (count($parts) > 0 ? $parts[0] : "less than a second");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full URL to short URL by removing protocol
|
||||
*
|
||||
* Strips http:// or https:// from the beginning of the URL if present.
|
||||
* Leaves the URL alone if it doesn't start with either protocol.
|
||||
* Removes trailing slash if there is no path.
|
||||
*
|
||||
* @param string|null $url URL to convert
|
||||
* @return string|null Short URL without protocol
|
||||
*/
|
||||
function full_url_to_short_url(?string $url): ?string
|
||||
{
|
||||
if ($url === null || $url === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Remove http:// or https:// from the beginning
|
||||
if (stripos($url, 'http://') === 0) {
|
||||
$url = substr($url, 7);
|
||||
} elseif (stripos($url, 'https://') === 0) {
|
||||
$url = substr($url, 8);
|
||||
}
|
||||
|
||||
// Remove trailing slash if there is no path (just domain)
|
||||
// Check if URL is just domain with trailing slash (no path after slash)
|
||||
if (substr($url, -1) === '/' && substr_count($url, '/') === 1) {
|
||||
$url = rtrim($url, '/');
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a short URL to full URL by adding protocol
|
||||
*
|
||||
* Adds http:// to the beginning of the URL if it lacks a protocol.
|
||||
* Leaves URLs with existing http:// or https:// unchanged.
|
||||
* Adds trailing slash if there is no path.
|
||||
*
|
||||
* @param string|null $url URL to convert
|
||||
* @return string|null Full URL with protocol
|
||||
*/
|
||||
function short_url_to_full_url(?string $url): ?string
|
||||
{
|
||||
if ($url === null || $url === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Check if URL already has a protocol
|
||||
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
|
||||
$full_url = $url;
|
||||
} else {
|
||||
// Add http:// protocol
|
||||
$full_url = 'http://' . $url;
|
||||
}
|
||||
|
||||
// Add trailing slash if there is no path (just domain)
|
||||
// Check if URL has no slash after the domain
|
||||
$without_protocol = preg_replace('#^https?://#i', '', $full_url);
|
||||
if (strpos($without_protocol, '/') === false) {
|
||||
$full_url .= '/';
|
||||
}
|
||||
|
||||
return $full_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a short URL format
|
||||
*
|
||||
* Validates that a URL (without protocol) looks like a valid domain.
|
||||
* Requirements:
|
||||
* - Must contain at least one dot (.)
|
||||
* - Must not contain spaces
|
||||
* - Empty strings are considered valid (optional field)
|
||||
*
|
||||
* @param string|null $url Short URL to validate
|
||||
* @return bool True if valid, false otherwise
|
||||
*/
|
||||
function validate_short_url(?string $url): bool
|
||||
{
|
||||
// Empty strings are valid (optional field)
|
||||
if ($url === null || $url === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Must not contain spaces
|
||||
if (strpos($url, ' ') !== false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must contain at least one dot (domain.extension)
|
||||
if (strpos($url, '.') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
306
app/RSpade/man/database_schema_architecture.txt
Executable file
306
app/RSpade/man/database_schema_architecture.txt
Executable file
@@ -0,0 +1,306 @@
|
||||
DATABASE SCHEMA ARCHITECTURE
|
||||
============================
|
||||
|
||||
OVERVIEW
|
||||
--------
|
||||
This document describes the RSpade database schema organization and the architectural
|
||||
principles governing how framework and application data is stored.
|
||||
|
||||
Date: 2024-11-18
|
||||
Status: Active framework architecture documentation
|
||||
|
||||
TABLE NAMING CONVENTIONS
|
||||
-------------------------
|
||||
RSpade uses table name prefixes to distinguish between different layers of the system:
|
||||
|
||||
1. SYSTEM TABLES (underscore prefix: _tablename)
|
||||
- Internal framework infrastructure
|
||||
- Managed entirely by framework subsystems
|
||||
- NOT meant to be accessed directly by application developers
|
||||
- May use direct DB::table() queries (exempt from ORM requirement)
|
||||
- Implementation details hidden from application layer
|
||||
|
||||
2. CORE TABLES (no prefix: tablename)
|
||||
- Built-in application infrastructure
|
||||
- Provided by framework but application-facing
|
||||
- Developers query, extend, and build upon these
|
||||
- MUST use ORM models (DB::table() prohibited)
|
||||
- Part of the developer-facing API
|
||||
|
||||
3. APPLICATION TABLES (no prefix: tablename)
|
||||
- Created by developers for their specific application
|
||||
- Business domain entities
|
||||
- MUST use ORM models (DB::table() prohibited)
|
||||
- Not shipped with framework (starter template only)
|
||||
|
||||
DATABASE ACCESS RULES
|
||||
----------------------
|
||||
The underscore prefix has enforcement implications:
|
||||
|
||||
SYSTEM TABLES (_tablename):
|
||||
- Direct queries allowed: DB::table('_task_queue')->where(...)->get()
|
||||
- No ORM model required
|
||||
- Code quality checker (PHP-DB-01) automatically skips these
|
||||
- Framework code optimizes for performance over abstraction
|
||||
|
||||
CORE & APPLICATION TABLES (tablename):
|
||||
- Direct queries PROHIBITED
|
||||
- ORM model REQUIRED
|
||||
- Code quality checker enforces this rule
|
||||
- Example: Client_Model::where(...)->get()
|
||||
|
||||
CURRENT SYSTEM TABLES
|
||||
----------------------
|
||||
|
||||
_migrations
|
||||
Purpose: Laravel migration tracking system
|
||||
Managed by: Laravel framework migration subsystem
|
||||
Records: Migration batch history, execution timestamps
|
||||
|
||||
_task_queue
|
||||
Purpose: Background job queue and scheduled task execution
|
||||
Managed by: RSpade Task system (Task.php, Task_Instance.php)
|
||||
Records: Queued jobs, scheduled tasks, execution logs, status tracking
|
||||
Schema: class, method, params, status, logs, scheduled_for, worker_pid
|
||||
|
||||
_file_storage
|
||||
Purpose: Content-addressable blob storage backend
|
||||
Managed by: RSpade File attachment system
|
||||
Records: Deduplicated file blobs indexed by SHA-256 hash
|
||||
Implementation: Single-instance storage pattern - multiple attachments can
|
||||
reference the same blob. Developers never access this directly.
|
||||
|
||||
_file_attachments
|
||||
Purpose: Polymorphic file attachment tracking
|
||||
Managed by: RSpade File attachment API
|
||||
Records: Attachment metadata, relationships, dimensions, session tracking
|
||||
Schema: key (UUID), file_storage_id (FK), fileable_type/id (polymorphic),
|
||||
fileable_category, site_id, session_id
|
||||
Developer API: Attachment methods, not direct table access
|
||||
|
||||
_flash_alerts
|
||||
Purpose: Transient flash message queue
|
||||
Managed by: Flash_Alert system
|
||||
Records: Session-scoped UI messages (success, error, warning, info)
|
||||
Lifecycle: Created on one request, displayed on next, then deleted
|
||||
Schema: session_id, type_id, message, timestamps
|
||||
|
||||
_search_indexes
|
||||
Purpose: Full-text search index storage
|
||||
Managed by: RSpade Search system
|
||||
Records: Extracted searchable content from polymorphic entities
|
||||
Schema: indexable_type/id (polymorphic), content (full-text), metadata (JSON),
|
||||
extraction_method, language, site_id
|
||||
Implementation: Automatic extraction from models, search API queries this
|
||||
|
||||
CURRENT CORE TABLES
|
||||
--------------------
|
||||
|
||||
sites
|
||||
Purpose: Multi-tenancy root table
|
||||
Managed by: Multi-tenant subsystem
|
||||
Records: Tenant organizations, each with isolated data space
|
||||
Usage: Developers query to show "available sites", build tenant switchers
|
||||
Schema: name, domain, settings, active status
|
||||
|
||||
users
|
||||
Purpose: User account storage
|
||||
Managed by: Authentication subsystem
|
||||
Records: User credentials, profile data, authentication state
|
||||
Usage: Developers extend with additional fields, query for user listings
|
||||
Schema: email, password_hash, name, site_id (FK), active status
|
||||
|
||||
login_users
|
||||
Purpose: Authentication session/token tracking
|
||||
Managed by: RsxAuth system
|
||||
Records: Active login sessions with 365-day persistence
|
||||
Usage: Developers query for "active sessions", implement logout-all
|
||||
Schema: user_id (FK), session_token, ip_address, last_activity, expires_at
|
||||
|
||||
user_profiles
|
||||
Purpose: Extended user profile information
|
||||
Managed by: User management system
|
||||
Records: Additional user data beyond core authentication
|
||||
Usage: Developers customize profile fields for their application
|
||||
Schema: user_id (FK), bio, avatar, preferences (JSON), custom fields
|
||||
|
||||
user_verifications
|
||||
Purpose: Email verification and password reset token storage
|
||||
Managed by: Authentication subsystem
|
||||
Records: Temporary verification codes with expiration
|
||||
Lifecycle: Created on request, validated once, then deleted or expired
|
||||
Schema: user_id (FK), token, type (email_verify, password_reset), expires_at
|
||||
|
||||
user_invites
|
||||
Purpose: User invitation system
|
||||
Managed by: User invitation subsystem
|
||||
Records: Pending invitations with expiration (48 hours default)
|
||||
Lifecycle: Created by admin, accepted by invitee, then marked used or expired
|
||||
Schema: site_id (FK), email, token, invited_by (FK), expires_at, accepted_at
|
||||
|
||||
ip_addresses
|
||||
Purpose: IP address tracking and audit trail
|
||||
Managed by: Security/audit subsystem
|
||||
Records: IP addresses associated with user actions
|
||||
Usage: Developers query for login history, security audit reports
|
||||
Schema: ip_address, user_id (FK), action_type, created_at
|
||||
|
||||
file_attachments (DEPRECATED - will become _file_attachments)
|
||||
See SYSTEM TABLES section above. Currently core, moving to system.
|
||||
|
||||
CURRENT APPLICATION TABLES
|
||||
---------------------------
|
||||
These tables are part of the starter template demonstration application.
|
||||
Developers typically replace these with their own domain entities.
|
||||
|
||||
clients
|
||||
Purpose: Demo business entity - client/customer records
|
||||
Business domain: B2B SaaS starter template
|
||||
Schema: name, email, phone, address, site_id (FK), status
|
||||
|
||||
client_departments
|
||||
Purpose: Demo organizational structure
|
||||
Business domain: Client organization hierarchy
|
||||
Schema: client_id (FK), name, parent_id (FK for nested structure)
|
||||
|
||||
contacts
|
||||
Purpose: Demo contact management
|
||||
Business domain: People associated with clients
|
||||
Schema: client_id (FK), name, email, phone, title, site_id (FK)
|
||||
|
||||
projects
|
||||
Purpose: Demo project tracking
|
||||
Business domain: Client projects/engagements
|
||||
Schema: client_id (FK), name, description, status, priority, due_date
|
||||
|
||||
tasks (NOTE: Confusing name - different from _task_queue!)
|
||||
Purpose: Demo user TODO/task list
|
||||
Business domain: User task management
|
||||
Schema: title, description, taskable_type/id (polymorphic), status, priority,
|
||||
assigned_to_user_id (FK), due_date, site_id (FK)
|
||||
WARNING: Name collision with _task_queue (background jobs). Consider renaming to
|
||||
user_task_queue or todo_items in future versions.
|
||||
|
||||
demo_products
|
||||
Purpose: Demo product catalog
|
||||
Business domain: Product/service offerings
|
||||
Schema: name, description, price, sku, category, active status
|
||||
|
||||
countries
|
||||
Purpose: Geographic reference data
|
||||
Business domain: International address support
|
||||
Records: ISO country codes and names
|
||||
Note: Reference data table, could be considered infrastructure
|
||||
|
||||
regions
|
||||
Purpose: Geographic subdivision reference data
|
||||
Business domain: State/province/region for addresses
|
||||
Records: Country subdivisions
|
||||
Note: Reference data table, could be considered infrastructure
|
||||
|
||||
ARCHITECTURAL PRINCIPLES
|
||||
-------------------------
|
||||
|
||||
1. SEPARATION OF CONCERNS
|
||||
System tables implement framework features invisibly. Core tables provide
|
||||
developer-facing infrastructure. Application tables express business domain.
|
||||
|
||||
2. ABSTRACTION BOUNDARIES
|
||||
System table schemas are implementation details. Core table schemas are API
|
||||
contracts. System tables can change without migration pain if APIs remain stable.
|
||||
|
||||
3. PERFORMANCE VS CONSISTENCY
|
||||
System tables prioritize performance (direct queries, denormalization OK).
|
||||
Core/Application tables prioritize data integrity (ORM, relationships, validation).
|
||||
|
||||
4. MIGRATION STRATEGY
|
||||
All migrations run chronologically when `php artisan migrate` is executed,
|
||||
regardless of whether they affect system, core, or application tables.
|
||||
|
||||
System tables: Migrations written and shipped by framework developers
|
||||
Core tables: Migrations written and shipped by framework developers
|
||||
Application tables: Migrations written by application developers
|
||||
|
||||
When a developer does `git pull` and new framework migrations exist, they
|
||||
run alongside application migrations in date order when migrate is executed.
|
||||
|
||||
5. DOCUMENTATION LEVEL
|
||||
System tables: Internal documentation only
|
||||
Core tables: Full API documentation required
|
||||
Application tables: Developer-documented
|
||||
|
||||
FUTURE CONSIDERATIONS
|
||||
---------------------
|
||||
|
||||
Planned Changes:
|
||||
- Rename 'migrations' to '_migrations' (Laravel supports configuring this)
|
||||
- Move 'file_attachments' to '_file_attachments' (pure system implementation detail)
|
||||
- Consider renaming 'tasks' to 'user_task_queue' or 'todo_items' to avoid confusion with '_task_queue'
|
||||
|
||||
Potential System Tables:
|
||||
- _cache (if implementing database cache driver)
|
||||
- _jobs (if using database queue driver - currently using '_task_queue')
|
||||
- _notifications (if implementing notification queue)
|
||||
- _websocket_connections (if implementing WebSocket presence tracking)
|
||||
|
||||
MIGRATION MECHANICS
|
||||
-------------------
|
||||
|
||||
When renaming a table to add underscore prefix:
|
||||
|
||||
1. Create migration to rename table
|
||||
2. Update all Model classes: protected $table = '_newtablename';
|
||||
3. Update direct queries in framework code
|
||||
4. Update foreign key references in other tables (if any)
|
||||
5. Update seeders, factories, tests
|
||||
6. Run code quality checker to find missed references
|
||||
7. Test all affected subsystems
|
||||
|
||||
Example migration:
|
||||
Schema::rename('file_storage', '_file_storage');
|
||||
|
||||
The underscore prefix is purely a naming convention - database engines treat
|
||||
these identically to non-prefixed tables. The convention exists for developer
|
||||
clarity and code quality enforcement.
|
||||
|
||||
DEVELOPER GUIDANCE
|
||||
------------------
|
||||
|
||||
When creating new tables, ask:
|
||||
|
||||
Q: Will application developers query this directly?
|
||||
YES → Core/Application table (no prefix)
|
||||
NO → System table (underscore prefix)
|
||||
|
||||
Q: Is this an implementation detail of a framework subsystem?
|
||||
YES → System table (underscore prefix)
|
||||
NO → Core/Application table (no prefix)
|
||||
|
||||
Q: Would changing the schema break the developer-facing API?
|
||||
YES → Core table (no prefix) - schema is part of the API contract
|
||||
NO → System table (underscore prefix) - implementation detail
|
||||
|
||||
Examples:
|
||||
- User login sessions? System (_login_sessions) - API is RsxAuth methods
|
||||
- User accounts? Core (users) - developers extend and query this
|
||||
- Search index? System (_search_indexes) - API is Search::query()
|
||||
- Client records? Application (clients) - business domain entity
|
||||
|
||||
CONCLUSION
|
||||
----------
|
||||
|
||||
The underscore prefix system creates a clear architectural boundary between
|
||||
framework implementation details and developer-facing data structures. This
|
||||
enables the framework to optimize system tables for performance while maintaining
|
||||
API stability, and helps developers understand which tables are theirs to use
|
||||
versus which are managed by framework subsystems.
|
||||
|
||||
For questions or clarification, consult:
|
||||
- /system/app/RSpade/Core/Database/CLAUDE.md - Database subsystem documentation
|
||||
- /system/app/RSpade/Core/Task/CLAUDE.md - Task system documentation
|
||||
- /system/app/RSpade/Core/Files/CLAUDE.md - File attachment documentation
|
||||
- /system/app/RSpade/CodeQuality/CLAUDE.md - Code quality rules including PHP-DB-01
|
||||
|
||||
---
|
||||
Document maintained by: Framework development team
|
||||
Last updated: 2024-11-18
|
||||
@@ -234,41 +234,69 @@ EVENT BINDING (⚠️ VERIFY FUNCTIONALITY)
|
||||
⚠️ TODO: Verify @event binding syntax is functional in current jqhtml version.
|
||||
|
||||
THIS.ARGS VS THIS.DATA
|
||||
Two distinct data sources in components:
|
||||
Two distinct data sources with strict lifecycle rules:
|
||||
|
||||
this.args - Component Input Parameters:
|
||||
- Source: Attributes from component invocation ($attr=value)
|
||||
- When Set: During component construction
|
||||
- Purpose: Configuration and input parameters
|
||||
- Mutability: Treat as read-only
|
||||
- Examples: User ID, theme, callback functions
|
||||
this.args - Component State (what to load):
|
||||
- Source: Passed from parent via $attr=value
|
||||
- Purpose: Component state that determines what on_load() fetches
|
||||
- Mutability: Modifiable in all methods EXCEPT on_load()
|
||||
- Usage: Page numbers, filters, sort order, configuration
|
||||
- Examples: this.args.page, this.args.filter, this.args.user_id
|
||||
|
||||
this.data - Async Loaded Data:
|
||||
- Source: on_load() lifecycle method
|
||||
- When Set: During load phase (after render, before ready)
|
||||
- Purpose: Dynamic data from APIs or computations
|
||||
- Mutability: Can be modified, triggers re-render
|
||||
this.data - Loaded Data (from APIs):
|
||||
- Source: Set in on_load() lifecycle method
|
||||
- Purpose: Data fetched from APIs based on this.args
|
||||
- Mutability: ONLY modifiable in on_create() and on_load()
|
||||
- Freeze Cycle: Frozen after on_create(), unfrozen during on_load(),
|
||||
frozen again after on_load() completes
|
||||
- Initial State: Empty object {} on first render
|
||||
- Examples: API responses, processed data, cached values
|
||||
- Examples: API responses, fetched records, computed results
|
||||
|
||||
Lifecycle Restrictions (ENFORCED):
|
||||
- on_create(): Can modify this.data (set defaults)
|
||||
- on_load(): Can ONLY access this.args and this.data
|
||||
Cannot access this.$, this.$id(), or any other properties
|
||||
Can modify this.data freely
|
||||
- on_ready() / event handlers: Can modify this.args, read this.data
|
||||
CANNOT modify this.data (frozen)
|
||||
|
||||
State Management Pattern:
|
||||
class Product_List extends Jqhtml_Component {
|
||||
on_create() {
|
||||
// Set default state for on_load()
|
||||
this.args.filter = this.args.filter || 'all';
|
||||
this.args.page = this.args.page || 1;
|
||||
|
||||
// Set default data for template
|
||||
this.data.products = [];
|
||||
this.data.loading = false;
|
||||
}
|
||||
|
||||
Example usage:
|
||||
class UserCard extends Jqhtml_Component {
|
||||
async on_load() {
|
||||
// this.data starts as {}
|
||||
const userId = this.args.userId; // From $user_id=...
|
||||
// Read state from this.args
|
||||
const filter = this.args.filter;
|
||||
const page = this.args.page;
|
||||
|
||||
// Load data and store in this.data
|
||||
this.data = await fetch(`/api/users/${userId}`)
|
||||
.then(r => r.json());
|
||||
// Triggers automatic re-render with populated data
|
||||
// Fetch and set this.data
|
||||
this.data = await Product_Controller.list({
|
||||
filter: filter,
|
||||
page: page
|
||||
});
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
console.log(this.data.name); // Loaded data
|
||||
console.log(this.args.theme); // Input parameter
|
||||
// Modify state, then reload
|
||||
this.$id('filter_btn').on('click', () => {
|
||||
this.args.filter = 'active'; // Change state
|
||||
this.reload(); // Re-fetch with new state
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Error Messages:
|
||||
Violations throw runtime errors with detailed fix suggestions.
|
||||
Example: "Cannot modify this.data outside of on_create() or on_load()."
|
||||
|
||||
COMMON PATTERNS AND PITFALLS
|
||||
Correct usage examples:
|
||||
<!-- Pass object reference -->
|
||||
@@ -299,20 +327,8 @@ COMMON PATTERNS AND PITFALLS
|
||||
}
|
||||
|
||||
CONTROL FLOW AND LOOPS
|
||||
Templates support full JavaScript control flow with two syntax styles:
|
||||
Templates support full JavaScript control flow using brace syntax:
|
||||
|
||||
Colon Syntax (PHP-like):
|
||||
<% if (this.data.show): %>
|
||||
<div>Visible content</div>
|
||||
<% else: %>
|
||||
<div>Hidden content</div>
|
||||
<% endif; %>
|
||||
|
||||
<% for (let item of this.data.items): %>
|
||||
<li><%= item.name %></li>
|
||||
<% endfor; %>
|
||||
|
||||
Brace Syntax (JavaScript-like):
|
||||
<% if (this.data.show) { %>
|
||||
<div>Visible content</div>
|
||||
<% } else { %>
|
||||
@@ -385,12 +401,15 @@ COMPONENT LIFECYCLE
|
||||
- Parent completes before children
|
||||
|
||||
4. on_load() (bottom-up, siblings in parallel, CAN be async)
|
||||
- Load async data
|
||||
- Load async data based on this.args
|
||||
- ONLY access this.args and this.data (RESTRICTED)
|
||||
- CANNOT access this.$, this.$id(), or any other properties
|
||||
- ONLY modify this.data - NEVER DOM
|
||||
- NO child component access
|
||||
- Siblings at same depth execute in parallel
|
||||
- Children complete before parent
|
||||
- If this.data changes, triggers automatic re-render
|
||||
- Runtime enforces access restrictions with clear errors
|
||||
|
||||
5. on_ready() (bottom-up)
|
||||
- All children guaranteed ready
|
||||
@@ -737,14 +756,14 @@ DOM CLASS CONVENTION
|
||||
All instantiated jqhtml components receive CSS classes on their
|
||||
DOM elements:
|
||||
|
||||
- 'Jqhtml_Component' - All components
|
||||
- 'Component' - All components
|
||||
- Component name (e.g., 'User_Card') - For targeting
|
||||
|
||||
// Select all jqhtml components on the page
|
||||
const components = $('.Jqhtml_Component');
|
||||
const components = $('.Component');
|
||||
|
||||
// Check if an element is a jqhtml component
|
||||
if ($element.hasClass('Jqhtml_Component')) {
|
||||
if ($element.hasClass('Component')) {
|
||||
// This is a jqhtml component
|
||||
}
|
||||
|
||||
@@ -887,13 +906,13 @@ LIFECYCLE MANIPULATION METHODS
|
||||
|
||||
Method Summary (jqhtml v2.2.182+):
|
||||
|
||||
reload() - Calls on_load() then render() - always re-renders
|
||||
reload() - Restore this.data to defaults, call on_load(), then render()
|
||||
render() - Re-renders with full lifecycle (waits for children, calls on_ready)
|
||||
redraw() - Alias for render()
|
||||
|
||||
reload()
|
||||
Re-fetch data and re-render - Calls on_load() then render() with full
|
||||
lifecycle. Always re-renders after loading data.
|
||||
Re-fetch data and re-render - Restores this.data to on_create() state,
|
||||
calls on_load() to fetch fresh data, then renders.
|
||||
|
||||
Usage:
|
||||
class User_Card extends Jqhtml_Component {
|
||||
@@ -901,16 +920,24 @@ LIFECYCLE MANIPULATION METHODS
|
||||
await this.reload(); // Fetches fresh data
|
||||
console.log('User data updated');
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
this.$id('filter_btn').on('click', async () => {
|
||||
this.args.filter = 'active'; // Update state
|
||||
await this.reload(); // Re-fetch with new state
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Behavior:
|
||||
- Calls on_load() to fetch fresh data
|
||||
- Restores this.data to snapshot from on_create()
|
||||
- Calls on_load() to fetch fresh data based on current this.args
|
||||
- Always re-renders (calls render() with full lifecycle)
|
||||
- Waits for children and calls on_ready()
|
||||
- Returns promise - await for completion
|
||||
- Use when: Need fresh data from server/API
|
||||
- Use when: this.args changed and need to re-fetch data
|
||||
|
||||
Lifecycle: on_load() → render() → on_ready() → trigger('ready')
|
||||
Lifecycle: restore this.data → on_load() → render() → on_ready() → trigger('ready')
|
||||
|
||||
render()
|
||||
Re-render component - Re-executes template with full lifecycle,
|
||||
@@ -936,23 +963,9 @@ LIFECYCLE MANIPULATION METHODS
|
||||
|
||||
Note: redraw() is an alias for render()
|
||||
|
||||
reinitialize()
|
||||
Full component reset - Restarts entire lifecycle from stage 0
|
||||
(nuclear option).
|
||||
|
||||
Usage:
|
||||
class Dashboard extends Jqhtml_Component {
|
||||
async switch_user(new_user_id) {
|
||||
this.args.user_id = new_user_id;
|
||||
await this.reinitialize(); // Complete reset
|
||||
}
|
||||
}
|
||||
|
||||
Behavior:
|
||||
- Stops current component state
|
||||
- Re-runs entire lifecycle: render → on_render → create → load → ready
|
||||
- Use when: Component needs complete rebuild
|
||||
- Rare use case - usually reload() or render() sufficient
|
||||
NOTE: reinitialize() method removed in v2.2.201.
|
||||
Use reload() for re-fetching data (99% of cases).
|
||||
For complete reset, remove component and create new instance.
|
||||
|
||||
stop()
|
||||
Component stopping - Removes component and all children from DOM.
|
||||
@@ -1035,9 +1048,26 @@ DOM UTILITIES
|
||||
This is genuine jQuery - all methods work directly
|
||||
|
||||
this.$id(name)
|
||||
Get element by scoped ID
|
||||
Get scoped element as jQuery object
|
||||
Example: this.$id('edit') gets element with $id="edit"
|
||||
Returns jQuery object
|
||||
Returns jQuery object, NOT component instance
|
||||
|
||||
this.id(name)
|
||||
Get scoped child component instance directly
|
||||
Example: this.id('my_component') gets component instance
|
||||
Returns component instance, NOT jQuery object
|
||||
|
||||
CRITICAL: this.$id() vs this.id() distinction
|
||||
- this.$id('foo') → jQuery object (for DOM manipulation)
|
||||
- this.id('foo') → Component instance (for calling methods)
|
||||
|
||||
Common mistake:
|
||||
const comp = this.id('foo').component(); // ❌ WRONG
|
||||
const comp = this.id('foo'); // ✅ CORRECT
|
||||
|
||||
Getting component from jQuery:
|
||||
const $elem = this.$id('foo');
|
||||
const comp = $elem.component(); // ✅ CORRECT (jQuery → component)
|
||||
|
||||
this.data
|
||||
Component data object
|
||||
|
||||
@@ -12,7 +12,7 @@ DESCRIPTION
|
||||
|
||||
Key differences from Laravel:
|
||||
- Laravel: route('user.profile', $user) using named routes
|
||||
- RSX: Rsx::Route('User_Controller', 'profile', ['id' => $user->id])
|
||||
- RSX: Rsx::Route('User_Controller::profile', ['id' => $user->id])
|
||||
|
||||
Benefits:
|
||||
- No route name management required
|
||||
@@ -22,24 +22,33 @@ DESCRIPTION
|
||||
- Refactoring-safe (renaming controllers updates routes)
|
||||
|
||||
BASIC USAGE
|
||||
Signature:
|
||||
Rsx::Route($action, $params = null)
|
||||
Rsx.Route(action, params = null)
|
||||
|
||||
Where:
|
||||
- $action: Controller class, SPA action, or "Class::method"
|
||||
Defaults to 'index' method if no :: present
|
||||
- $params: Integer sets 'id', array/object provides named params
|
||||
|
||||
PHP Syntax:
|
||||
use App\RSpade\Core\Rsx;
|
||||
|
||||
// Generate URLs (returns string directly)
|
||||
$url = Rsx::Route('Demo_Index_Controller'); // /demo
|
||||
$url = Rsx::Route('Demo_Index_Controller', 'show'); // /demo/show
|
||||
$url = Rsx::Route('Demo_Index_Controller', 'show', ['id' => 123]); // /demo/123
|
||||
$url = Rsx::Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
|
||||
// Generate URLs (defaults to 'index' method)
|
||||
$url = Rsx::Route('Demo_Index_Controller'); // /demo
|
||||
$url = Rsx::Route('Demo_Index_Controller::show'); // /demo/show
|
||||
$url = Rsx::Route('Demo_Index_Controller::show', 123); // /demo/123
|
||||
$url = Rsx::Route('Demo_Index_Controller::show', ['id' => 123]); // /demo/123
|
||||
|
||||
// Use in redirects
|
||||
return redirect(Rsx::Route('Demo_Index_Controller'));
|
||||
|
||||
JavaScript Syntax:
|
||||
// Generate URLs (returns string directly)
|
||||
const url = Rsx.Route('Demo_Index_Controller'); // /demo
|
||||
const url = Rsx.Route('Demo_Index_Controller', 'show'); // /demo/show
|
||||
const url = Rsx.Route('Demo_Index_Controller', 'show', {id: 123}); // /demo/123
|
||||
const url = Rsx.Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
|
||||
// Generate URLs (defaults to 'index' method)
|
||||
const url = Rsx.Route('Demo_Index_Controller'); // /demo
|
||||
const url = Rsx.Route('Demo_Index_Controller::show'); // /demo/show
|
||||
const url = Rsx.Route('Demo_Index_Controller::show', 123); // /demo/123
|
||||
const url = Rsx.Route('Demo_Index_Controller::show', {id: 123}); // /demo/123
|
||||
|
||||
// Use in navigation
|
||||
window.location.href = Rsx.Route('Demo_Index_Controller');
|
||||
@@ -143,17 +152,17 @@ ADVANCED PATTERNS
|
||||
Complex Parameter Examples:
|
||||
// Multiple parameters
|
||||
#[Route('/api/v1/users/:company/:division/:id')]
|
||||
$url = Rsx::Route('Api_V1_Users_Controller', 'show',
|
||||
$url = Rsx::Route('Api_V1_Users_Controller::show',
|
||||
['company' => 'acme', 'division' => 'sales', 'id' => 123]);
|
||||
// Result: /api/v1/users/acme/sales/123
|
||||
|
||||
// Query parameters for extra values
|
||||
$url = Rsx::Route('Demo_Controller', 'show',
|
||||
$url = Rsx::Route('Demo_Controller::show',
|
||||
['id' => 123, 'format' => 'json', 'include' => 'profile']);
|
||||
// Result: /demo/123?format=json&include=profile
|
||||
|
||||
// Complex objects as parameters
|
||||
$url = Rsx::Route('Demo_Controller', 'index',
|
||||
$url = Rsx::Route('Demo_Controller',
|
||||
['filter' => ['status' => 'active', 'type' => 'user']]);
|
||||
// Result: /demo?filter[status]=active&filter[type]=user
|
||||
|
||||
@@ -230,12 +239,12 @@ COMMON PATTERNS
|
||||
|
||||
AJAX URL Generation:
|
||||
// Generate URLs for AJAX calls
|
||||
const apiUrl = Rsx.Route('Api_User_Controller', 'update', {id: userId});
|
||||
const apiUrl = Rsx.Route('Api_User_Controller::update', {id: userId});
|
||||
fetch(apiUrl, {method: 'POST', body: formData});
|
||||
|
||||
Form Action URLs:
|
||||
// Generate form action URLs
|
||||
<form action="<?= Rsx::Route('User_Profile_Controller', 'update') ?>" method="POST">
|
||||
<form action="<?= Rsx::Route('User_Profile_Controller::update') ?>" method="POST">
|
||||
|
||||
Link Generation:
|
||||
// Generate navigation links
|
||||
|
||||
@@ -504,21 +504,31 @@ TROUBLESHOOTING
|
||||
- Use has_session() to check without creating
|
||||
|
||||
GARBAGE COLLECTION
|
||||
Expired Session Cleanup:
|
||||
Sessions older than 365 days should be deleted periodically.
|
||||
Automatic Session Cleanup:
|
||||
Sessions are automatically cleaned up via scheduled task that runs
|
||||
daily at 3 AM. No manual configuration required.
|
||||
|
||||
Create scheduled command:
|
||||
php artisan make:command CleanupSessions
|
||||
Cleanup Rules:
|
||||
- Logged-in sessions (login_user_id set): Deleted after 365 days
|
||||
- Anonymous sessions (login_user_id null): Deleted after 14 days
|
||||
|
||||
In handle() method:
|
||||
$deleted = Session::cleanup_expired(365);
|
||||
$this->info("Deleted $deleted expired sessions");
|
||||
The cleanup uses last_active timestamp to determine session age.
|
||||
Sessions are permanently deleted from the database.
|
||||
|
||||
Schedule in app/Console/Kernel.php:
|
||||
$schedule->command('sessions:cleanup')->daily();
|
||||
Implementation:
|
||||
Service: Session_Cleanup_Service::cleanup_sessions()
|
||||
Schedule: Daily at 3 AM (via #[Schedule] attribute)
|
||||
Automatic: No cron configuration needed
|
||||
|
||||
The cleanup_expired() method deletes sessions where last_active
|
||||
is older than the specified number of days.
|
||||
Manual Trigger:
|
||||
To manually run cleanup outside the schedule:
|
||||
|
||||
php artisan rsx:task:run Session_Cleanup_Service cleanup_sessions
|
||||
|
||||
This is useful for:
|
||||
- Testing cleanup logic
|
||||
- Emergency cleanup when disk space is low
|
||||
- One-time cleanup after changing retention policies
|
||||
|
||||
SEE ALSO
|
||||
rsx:man routing - Type-safe URL generation
|
||||
|
||||
688
app/RSpade/man/spa.txt
Executable file
688
app/RSpade/man/spa.txt
Executable file
@@ -0,0 +1,688 @@
|
||||
SPA(3) RSX Framework Manual SPA(3)
|
||||
|
||||
NAME
|
||||
spa - Single Page Application routing for authenticated areas
|
||||
|
||||
SYNOPSIS
|
||||
PHP Bootstrap Controller (one per SPA module):
|
||||
|
||||
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
#[SPA]
|
||||
#[Auth('Permission::authenticated()')]
|
||||
public static function index(Request $request, array $params = [])
|
||||
{
|
||||
return rsx_view(SPA);
|
||||
}
|
||||
}
|
||||
|
||||
JavaScript Action (many per module):
|
||||
|
||||
@route('/contacts')
|
||||
@layout('Frontend_Layout')
|
||||
@spa('Frontend_Spa_Controller::index')
|
||||
class Contacts_Index_Action extends Spa_Action {
|
||||
async on_load() {
|
||||
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
|
||||
}
|
||||
}
|
||||
|
||||
DESCRIPTION
|
||||
The RSX SPA system provides client-side routing for authenticated application
|
||||
areas, enabling navigation without page reloads. Unlike traditional Laravel
|
||||
views where each navigation triggers a full page load, SPA modules bootstrap
|
||||
once and handle all subsequent navigation client-side through JavaScript
|
||||
actions.
|
||||
|
||||
This approach is fundamentally different from traditional server-side routing:
|
||||
- Traditional: Every navigation loads new HTML from server
|
||||
- SPA: First request bootstraps application, subsequent navigation is client-side
|
||||
|
||||
Key differences from Laravel:
|
||||
- Laravel: Full page reload per navigation, routes in routes/ files
|
||||
- RSX: Bootstrap once, routes defined in JavaScript with @route() decorator
|
||||
|
||||
- Laravel: Separate frontend frameworks (Vue, React) require build tooling
|
||||
- RSX: Integrated JQHTML component system, no external build required
|
||||
|
||||
- Laravel: API routes separate from view routes
|
||||
- RSX: Controllers provide Ajax endpoints, actions consume them
|
||||
|
||||
Benefits:
|
||||
- No page reloads after initial bootstrap
|
||||
- Persistent layout across navigation
|
||||
- Automatic browser history integration
|
||||
- Same Rsx::Route() syntax for both traditional and SPA routes
|
||||
- No frontend build tooling required
|
||||
|
||||
When to use:
|
||||
- Authenticated areas (dashboards, admin panels)
|
||||
- Applications with frequent navigation
|
||||
- Features requiring persistent state
|
||||
|
||||
When to avoid:
|
||||
- Public pages requiring SEO
|
||||
- Simple static content
|
||||
- Forms without complex interactions
|
||||
|
||||
SPA ARCHITECTURE
|
||||
SPA Module Structure:
|
||||
An SPA module consists of six component types:
|
||||
|
||||
1. PHP Bootstrap Controller (ONE per feature/bundle)
|
||||
- Single entry point marked with #[SPA]
|
||||
- Performs authentication check with failure/redirect
|
||||
- Renders SPA bootstrap layout
|
||||
- Referenced by all actions via @spa() decorator
|
||||
- One #[SPA] per feature/bundle (e.g., /app/frontend, /app/root, /app/login)
|
||||
- Bundles segregate code for security and performance:
|
||||
* Save bandwidth by loading only needed features
|
||||
* Reduce processing time by smaller bundle sizes
|
||||
* Protect confidential code (e.g., root admin from unauthorized users)
|
||||
- Typically one #[SPA] per feature at rsx/app/(feature)/(feature)_spa_controller::index
|
||||
|
||||
2. Feature Controllers (Ajax Endpoints Only)
|
||||
- Provide data via #[Ajax_Endpoint] methods
|
||||
- No #[SPA] or #[Route] methods
|
||||
- Called by actions to fetch/save data
|
||||
|
||||
3. JavaScript Actions (MANY per module)
|
||||
- Represent individual pages/routes
|
||||
- Define route patterns with @route()
|
||||
- Load data in on_load() lifecycle method
|
||||
- Access URL parameters via this.args
|
||||
|
||||
4. Action Templates (.jqhtml)
|
||||
- Render action content
|
||||
- Standard JQHTML component templates
|
||||
- Replace traditional Blade views
|
||||
|
||||
5. Layout Template (.jqhtml)
|
||||
- Persistent wrapper around actions
|
||||
- Must have element with $id="content"
|
||||
- Persists across action navigation
|
||||
|
||||
6. Layout Class (.js)
|
||||
- Extends Spa_Layout
|
||||
- Optional on_action() hook for navigation tracking
|
||||
|
||||
Execution Flow:
|
||||
First Request:
|
||||
1. User navigates to SPA route (e.g., /contacts)
|
||||
2. Dispatcher calls bootstrap controller
|
||||
3. Bootstrap returns rsx_view(SPA) with window.rsxapp.is_spa = true
|
||||
4. Client JavaScript discovers all actions via manifest
|
||||
5. Router matches URL to action class
|
||||
6. Creates layout on <body>
|
||||
7. Creates action inside layout $id="content" area
|
||||
|
||||
Subsequent Navigation:
|
||||
1. User clicks link or calls Spa.dispatch()
|
||||
2. Router matches URL to action class
|
||||
3. Destroys old action, creates new action
|
||||
4. Layout persists - no re-render
|
||||
5. No server request, no page reload
|
||||
|
||||
BOOTSTRAP CONTROLLER
|
||||
Creating SPA Entry Point:
|
||||
// /rsx/app/frontend/Frontend_Spa_Controller.php
|
||||
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
#[SPA]
|
||||
#[Auth('Permission::authenticated()')]
|
||||
public static function index(Request $request, array $params = [])
|
||||
{
|
||||
return rsx_view(SPA);
|
||||
}
|
||||
}
|
||||
|
||||
Key Points:
|
||||
- One bootstrap controller per feature/bundle
|
||||
- #[SPA] attribute marks as SPA entry point
|
||||
- #[Auth] performs server-side authentication with failure/redirect
|
||||
- Must return rsx_view(SPA) - special constant
|
||||
- All actions in the same bundle reference this via @spa() decorator
|
||||
|
||||
Bundle Segregation:
|
||||
- Separate bundles for different features: /app/frontend, /app/root, /app/login
|
||||
- Each bundle has its own #[SPA] bootstrap
|
||||
- Benefits:
|
||||
* Bandwidth: Users only download code for authorized features
|
||||
* Performance: Smaller bundles = faster load times
|
||||
* Security: Root admin code never sent to unauthorized users
|
||||
- Example: Frontend users never receive root admin JavaScript/CSS
|
||||
|
||||
Location:
|
||||
- Place at feature root: /rsx/app/{feature}/
|
||||
- Not in subdirectories
|
||||
- One per feature/bundle
|
||||
- Naming: {Feature}_Spa_Controller::index
|
||||
|
||||
Multiple SPA Bootstraps:
|
||||
Different features can have separate SPA bootstraps:
|
||||
- /app/frontend/Frontend_Spa_Controller::index (regular users)
|
||||
- /app/root/Root_Spa_Controller::index (root admins)
|
||||
- /app/login/Login_Spa_Controller::index (authentication flow)
|
||||
|
||||
Each bootstrap controls access to its feature's actions via #[Auth].
|
||||
|
||||
FEATURE CONTROLLERS
|
||||
Ajax Endpoint Pattern:
|
||||
Feature controllers provide data endpoints only, no routes or SPA entry:
|
||||
|
||||
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
|
||||
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
#[Ajax_Endpoint]
|
||||
public static function datagrid_fetch(Request $request, array $params = []): array
|
||||
{
|
||||
return Contacts_DataGrid::fetch($params);
|
||||
}
|
||||
|
||||
#[Ajax_Endpoint]
|
||||
public static function save(Request $request, array $params = [])
|
||||
{
|
||||
// Validation and save logic
|
||||
$contact->save();
|
||||
|
||||
Flash_Alert::success('Contact saved successfully');
|
||||
|
||||
return [
|
||||
'contact_id' => $contact->id,
|
||||
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Guidelines:
|
||||
- All methods are #[Ajax_Endpoint]
|
||||
- No #[Route] methods in SPA feature controllers
|
||||
- No #[SPA] methods (only in bootstrap controller)
|
||||
- Return arrays/data, not views
|
||||
- Use Flash_Alert with redirects for success messages
|
||||
|
||||
See Also:
|
||||
ajax_error_handling(3) for error patterns
|
||||
controller(3) for #[Ajax_Endpoint] details
|
||||
|
||||
JAVASCRIPT ACTIONS
|
||||
Creating Actions:
|
||||
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
|
||||
@route('/contacts')
|
||||
@layout('Frontend_Layout')
|
||||
@spa('Frontend_Spa_Controller::index')
|
||||
class Contacts_Index_Action extends Spa_Action {
|
||||
async on_load() {
|
||||
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
|
||||
}
|
||||
|
||||
on_create() {
|
||||
// Component initialization
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
// DOM is ready, setup event handlers
|
||||
this.$id('search').on('input', () => this.reload());
|
||||
}
|
||||
}
|
||||
|
||||
Decorator Syntax:
|
||||
@route(pattern)
|
||||
URL pattern with optional :param segments
|
||||
Example: '/contacts/:id', '/users/:id/posts/:post_id'
|
||||
|
||||
@layout(class_name)
|
||||
Layout class to render within
|
||||
Example: 'Frontend_Layout', 'Dashboard_Layout'
|
||||
|
||||
@spa(controller::method)
|
||||
References bootstrap controller
|
||||
Example: 'Frontend_Spa_Controller::index'
|
||||
|
||||
@title(page_title)
|
||||
Browser page title for this action (optional, clears if not present)
|
||||
Alternatively, set dynamically in on_ready() via document.title = "value"
|
||||
Example: @title('Contacts - RSX')
|
||||
|
||||
URL Parameters:
|
||||
Parameters from route pattern and query string available in this.args:
|
||||
|
||||
// URL: /contacts/123?tab=history
|
||||
@route('/contacts/:id')
|
||||
class Contacts_View_Action extends Spa_Action {
|
||||
on_create() {
|
||||
console.log(this.args.id); // "123"
|
||||
console.log(this.args.tab); // "history"
|
||||
}
|
||||
}
|
||||
|
||||
Lifecycle Methods:
|
||||
on_create() Component construction, setup this.state
|
||||
on_load() Fetch data, populate this.data (read-only this.args)
|
||||
on_render() Template rendering
|
||||
on_ready() DOM ready, setup event handlers
|
||||
|
||||
See Also:
|
||||
jqhtml(3) for complete lifecycle documentation
|
||||
|
||||
ACTION TEMPLATES
|
||||
Creating Templates:
|
||||
Action templates are standard JQHTML components:
|
||||
|
||||
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
|
||||
<Define:Contacts_Index_Action tag="main">
|
||||
<h1>Contacts</h1>
|
||||
|
||||
<div class="contacts-list">
|
||||
<% for (let contact of this.data.contacts) { %>
|
||||
<div class="contact-item">
|
||||
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
|
||||
<%= contact.name %>
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</Define:Contacts_Index_Action>
|
||||
|
||||
Key Points:
|
||||
- Use Rsx.Route() for all URLs, never hardcode
|
||||
- Access data via this.data (loaded in on_load)
|
||||
- Use standard JQHTML template syntax
|
||||
- Can reference other components
|
||||
|
||||
LAYOUTS
|
||||
Layout Template:
|
||||
Layout provides persistent wrapper around actions:
|
||||
|
||||
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
|
||||
<Define:Frontend_Layout>
|
||||
<div class="frontend-layout">
|
||||
<header class="mb-3">
|
||||
<h1>My App</h1>
|
||||
<nav>
|
||||
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
|
||||
<a href="<%= Rsx.Route('Projects_Index_Action') %>">Projects</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main $id="content">
|
||||
<!-- Actions render here -->
|
||||
</main>
|
||||
|
||||
<footer class="mt-3">
|
||||
<p>© 2024 My Company</p>
|
||||
</footer>
|
||||
</div>
|
||||
</Define:Frontend_Layout>
|
||||
|
||||
Requirements:
|
||||
- Must have element with $id="content"
|
||||
- Content area is where actions render
|
||||
- Layout persists across navigation
|
||||
|
||||
Layout Class:
|
||||
Optional JavaScript class for navigation tracking:
|
||||
|
||||
// /rsx/app/frontend/Frontend_Layout.js
|
||||
class Frontend_Layout extends Spa_Layout {
|
||||
on_action(url, action_name, args) {
|
||||
// Called AFTER action component created and booted
|
||||
// Called BEFORE action reaches on_ready()
|
||||
// Can immediately access this.action properties
|
||||
|
||||
// Update active nav highlighting
|
||||
this.$('.nav-link').removeClass('active');
|
||||
this.$(`[data-action="${action_name}"]`).addClass('active');
|
||||
|
||||
// If you need to wait for action's full lifecycle:
|
||||
// await this.action.ready();
|
||||
}
|
||||
}
|
||||
|
||||
on_action() Lifecycle Timing:
|
||||
- Called after action component is created and booted
|
||||
- Called before action reaches on_ready()
|
||||
- Can immediately access this.action properties
|
||||
- Use await this.action.ready() to wait for action's full loading
|
||||
|
||||
URL GENERATION
|
||||
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
|
||||
"/contacts" will produce errors.
|
||||
|
||||
Signature:
|
||||
Rsx::Route($action, $params = null)
|
||||
Rsx.Route(action, params = null)
|
||||
|
||||
Where:
|
||||
- $action: Controller class, SPA action, or "Class::method"
|
||||
Defaults to 'index' method if no :: present
|
||||
- $params: Integer sets 'id', array/object provides named params
|
||||
|
||||
PHP Syntax:
|
||||
// Works for both controller routes and SPA actions
|
||||
Rsx::Route('Contacts_Index_Action') // /contacts
|
||||
Rsx::Route('Contacts_View_Action', 123) // /contacts/123
|
||||
|
||||
JavaScript Syntax:
|
||||
// Works for both controller routes and SPA actions
|
||||
Rsx.Route('Contacts_Index_Action') // /contacts
|
||||
Rsx.Route('Contacts_View_Action', 123) // /contacts/123
|
||||
|
||||
Automatic Detection:
|
||||
Rsx::Route() automatically detects whether the class is:
|
||||
- PHP controller with #[Route] attribute
|
||||
- JavaScript SPA action with @route() decorator
|
||||
|
||||
Query Parameters:
|
||||
Extra parameters become query string:
|
||||
|
||||
Rsx::Route('Contacts_Index_Action', ['filter' => 'active', 'sort' => 'name'])
|
||||
// Result: /contacts?filter=active&sort=name
|
||||
|
||||
NAVIGATION
|
||||
Automatic Browser Integration:
|
||||
- Clicking links to SPA routes triggers client-side navigation
|
||||
- Browser back/forward buttons handled without page reload
|
||||
- URL updates in address bar via pushState()
|
||||
- Links to external or non-SPA routes perform full page load
|
||||
|
||||
Programmatic Navigation:
|
||||
Spa.dispatch(url)
|
||||
Navigate to URL programmatically
|
||||
If URL is part of current SPA, uses client-side routing
|
||||
If URL is external or different SPA, performs full page load
|
||||
|
||||
Example:
|
||||
// Navigate within SPA
|
||||
Spa.dispatch('/contacts/123');
|
||||
|
||||
// Navigate to external URL (full page load)
|
||||
Spa.dispatch('https://example.com');
|
||||
|
||||
Use Spa.dispatch() instead of window.location for all navigation.
|
||||
|
||||
Accessing Active Components:
|
||||
Spa.layout Reference to current layout component instance
|
||||
Spa.action Reference to current action component instance
|
||||
|
||||
Example:
|
||||
// From anywhere in SPA context
|
||||
Spa.action.reload(); // Reload current action
|
||||
Spa.layout.update_nav(); // Call layout method
|
||||
|
||||
FILE ORGANIZATION
|
||||
Standard SPA Module Structure:
|
||||
/rsx/app/frontend/
|
||||
|-- Frontend_Spa_Controller.php # Bootstrap controller
|
||||
|-- Frontend_Layout.js # Layout class
|
||||
|-- Frontend_Layout.jqhtml # Layout template
|
||||
|-- frontend_bundle.php # Bundle definition
|
||||
|
|
||||
|-- contacts/
|
||||
| |-- frontend_contacts_controller.php # Ajax endpoints
|
||||
| |-- Contacts_Index_Action.js # /contacts
|
||||
| |-- Contacts_Index_Action.jqhtml
|
||||
| |-- Contacts_View_Action.js # /contacts/:id
|
||||
| `-- Contacts_View_Action.jqhtml
|
||||
|
|
||||
`-- projects/
|
||||
|-- frontend_projects_controller.php
|
||||
|-- Projects_Index_Action.js
|
||||
`-- Projects_Index_Action.jqhtml
|
||||
|
||||
Naming Conventions:
|
||||
- Bootstrap controller: {Module}_Spa_Controller.php
|
||||
- Feature controllers: {feature}_{module}_controller.php
|
||||
- Actions: {Feature}_{Action}_Action.js
|
||||
- Templates: {Feature}_{Action}_Action.jqhtml
|
||||
- Layout: {Module}_Layout.js and .jqhtml
|
||||
|
||||
EXAMPLES
|
||||
Complete Contacts Module:
|
||||
|
||||
1. Bootstrap Controller:
|
||||
// /rsx/app/frontend/Frontend_Spa_Controller.php
|
||||
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
#[SPA]
|
||||
#[Auth('Permission::authenticated()')]
|
||||
public static function index(Request $request, array $params = [])
|
||||
{
|
||||
return rsx_view(SPA);
|
||||
}
|
||||
}
|
||||
|
||||
2. Feature Controller:
|
||||
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
|
||||
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
#[Ajax_Endpoint]
|
||||
public static function fetch_all(Request $request, array $params = []): array
|
||||
{
|
||||
$contacts = Contact_Model::query()
|
||||
->where('is_active', 1)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return $contacts->toArray();
|
||||
}
|
||||
|
||||
#[Ajax_Endpoint]
|
||||
public static function save(Request $request, array $params = [])
|
||||
{
|
||||
$contact = Contact_Model::findOrNew($params['id'] ?? null);
|
||||
$contact->fill($params);
|
||||
$contact->save();
|
||||
|
||||
Flash_Alert::success('Contact saved');
|
||||
|
||||
return [
|
||||
'contact_id' => $contact->id,
|
||||
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
3. List Action:
|
||||
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
|
||||
@route('/contacts')
|
||||
@layout('Frontend_Layout')
|
||||
@spa('Frontend_Spa_Controller::index')
|
||||
class Contacts_Index_Action extends Spa_Action {
|
||||
async on_load() {
|
||||
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
|
||||
}
|
||||
}
|
||||
|
||||
4. List Template:
|
||||
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
|
||||
<Define:Contacts_Index_Action tag="main">
|
||||
<h1>Contacts</h1>
|
||||
<% for (let contact of this.data.contacts) { %>
|
||||
<div>
|
||||
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
|
||||
<%= contact.name %>
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</Define:Contacts_Index_Action>
|
||||
|
||||
5. View Action with Parameters:
|
||||
// /rsx/app/frontend/contacts/Contacts_View_Action.js
|
||||
@route('/contacts/:id')
|
||||
@layout('Frontend_Layout')
|
||||
@spa('Frontend_Spa_Controller::index')
|
||||
class Contacts_View_Action extends Spa_Action {
|
||||
async on_load() {
|
||||
const contact_id = this.args.id;
|
||||
this.data.contact = await Contact_Model.fetch(contact_id);
|
||||
}
|
||||
}
|
||||
|
||||
6. Layout:
|
||||
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
|
||||
<Define:Frontend_Layout>
|
||||
<div class="app">
|
||||
<header>
|
||||
<nav>
|
||||
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main $id="content"></main>
|
||||
</div>
|
||||
</Define:Frontend_Layout>
|
||||
|
||||
SPA VS TRADITIONAL ROUTES
|
||||
Architecture Comparison:
|
||||
|
||||
Traditional Route:
|
||||
Every navigation:
|
||||
1. Browser sends HTTP request to server
|
||||
2. Controller executes, queries database
|
||||
3. Blade template renders full HTML page
|
||||
4. Server sends complete HTML to browser
|
||||
5. Browser parses and renders new page
|
||||
6. JavaScript re-initializes
|
||||
7. User sees page flash/reload
|
||||
|
||||
SPA Route:
|
||||
First navigation:
|
||||
1. Browser sends HTTP request to server
|
||||
2. Bootstrap controller renders SPA shell
|
||||
3. JavaScript initializes, discovers actions
|
||||
4. Router matches URL to action
|
||||
5. Action loads data via Ajax
|
||||
6. Action renders in persistent layout
|
||||
|
||||
Subsequent navigation:
|
||||
1. Router matches URL to action (client-side)
|
||||
2. New action loads data via Ajax
|
||||
3. New action renders in existing layout
|
||||
4. No server request, no page reload
|
||||
5. Layout persists (header/nav/footer stay)
|
||||
|
||||
Code Comparison:
|
||||
|
||||
Traditional:
|
||||
// Controller
|
||||
#[Route('/contacts')]
|
||||
public static function index(Request $request, array $params = []) {
|
||||
$contacts = Contact_Model::all();
|
||||
return rsx_view('Contacts_List', ['contacts' => $contacts]);
|
||||
}
|
||||
|
||||
// Blade view
|
||||
@extends('layout')
|
||||
@section('content')
|
||||
<h1>Contacts</h1>
|
||||
@foreach($contacts as $contact)
|
||||
<div>{{ $contact->name }}</div>
|
||||
@endforeach
|
||||
@endsection
|
||||
|
||||
SPA:
|
||||
// Bootstrap (once)
|
||||
#[SPA]
|
||||
public static function index(Request $request, array $params = []) {
|
||||
return rsx_view(SPA);
|
||||
}
|
||||
|
||||
// Ajax endpoint
|
||||
#[Ajax_Endpoint]
|
||||
public static function fetch_all(Request $request, array $params = []): array {
|
||||
return Contact_Model::all()->toArray();
|
||||
}
|
||||
|
||||
// Action
|
||||
@route('/contacts')
|
||||
@spa('Frontend_Spa_Controller::index')
|
||||
class Contacts_Index_Action extends Spa_Action {
|
||||
async on_load() {
|
||||
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
|
||||
}
|
||||
}
|
||||
|
||||
// Template
|
||||
<Define:Contacts_Index_Action>
|
||||
<h1>Contacts</h1>
|
||||
<% for (let contact of this.data.contacts) { %>
|
||||
<div><%= contact.name %></div>
|
||||
<% } %>
|
||||
</Define:Contacts_Index_Action>
|
||||
|
||||
TROUBLESHOOTING
|
||||
SPA Not Initializing:
|
||||
- Verify bootstrap controller has #[SPA] attribute
|
||||
- Check controller returns rsx_view(SPA)
|
||||
- Ensure bundle includes SPA directory
|
||||
- Verify window.rsxapp.is_spa is true in HTML
|
||||
|
||||
Action Not Found:
|
||||
- Check @route() decorator matches URL pattern
|
||||
- Verify action extends Spa_Action
|
||||
- Ensure action file included in bundle
|
||||
- Run: php artisan rsx:manifest:build
|
||||
|
||||
Navigation Not Working:
|
||||
- Verify using Rsx.Route() not hardcoded URLs
|
||||
- Check @spa() decorator references correct bootstrap controller
|
||||
- Ensure layout has $id="content" element
|
||||
- Test Spa.dispatch() directly
|
||||
|
||||
Layout Not Persisting:
|
||||
- Verify all actions in module use same @layout()
|
||||
- Check layout template has $id="content"
|
||||
- Ensure not mixing SPA and traditional routes
|
||||
|
||||
this.args Empty:
|
||||
- Check route pattern includes :param for dynamic segments
|
||||
- Verify URL matches route pattern exactly
|
||||
- Remember query params (?key=value) also in this.args
|
||||
|
||||
Data Not Loading:
|
||||
- Verify Ajax endpoint has #[Ajax_Endpoint]
|
||||
- Check endpoint returns array, not view
|
||||
- Ensure await used in on_load() for async calls
|
||||
- Verify controller included in bundle
|
||||
|
||||
COMMON PATTERNS
|
||||
Conditional Navigation:
|
||||
// Navigate based on condition
|
||||
if (user.is_admin) {
|
||||
Spa.dispatch(Rsx.Route('Admin_Dashboard_Action'));
|
||||
} else {
|
||||
Spa.dispatch(Rsx.Route('User_Dashboard_Action'));
|
||||
}
|
||||
|
||||
Reloading Current Action:
|
||||
// Refresh current action data
|
||||
Spa.action.reload();
|
||||
|
||||
Form Submission with Redirect:
|
||||
async on_submit() {
|
||||
const data = this.vals();
|
||||
const result = await Controller.save(data);
|
||||
|
||||
if (result.errors) {
|
||||
Form_Utils.apply_form_errors(this.$, result.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Flash alert set server-side, redirect to view
|
||||
Spa.dispatch(result.redirect);
|
||||
}
|
||||
|
||||
Multiple Layouts:
|
||||
// Different layouts for different areas
|
||||
@layout('Admin_Layout')
|
||||
class Admin_Users_Action extends Spa_Action { }
|
||||
|
||||
@layout('Frontend_Layout')
|
||||
class Frontend_Contacts_Action extends Spa_Action { }
|
||||
|
||||
SEE ALSO
|
||||
controller(3) - Controller patterns and Ajax endpoints
|
||||
jqhtml(3) - Component lifecycle and templates
|
||||
routing(3) - URL generation and route patterns
|
||||
modals(3) - Modal dialogs in SPA context
|
||||
ajax_error_handling(3) - Error handling patterns
|
||||
267
app/RSpade/man/storage.txt
Executable file
267
app/RSpade/man/storage.txt
Executable file
@@ -0,0 +1,267 @@
|
||||
RSX_STORAGE(1) RSpade Manual RSX_STORAGE(1)
|
||||
|
||||
NAME
|
||||
Rsx_Storage - Scoped browser storage helper with automatic fallback
|
||||
|
||||
SYNOPSIS
|
||||
// Session storage (cleared on tab close)
|
||||
Rsx_Storage.session_set(key, value)
|
||||
Rsx_Storage.session_get(key)
|
||||
Rsx_Storage.session_remove(key)
|
||||
|
||||
// Local storage (persists across sessions)
|
||||
Rsx_Storage.local_set(key, value)
|
||||
Rsx_Storage.local_get(key)
|
||||
Rsx_Storage.local_remove(key)
|
||||
|
||||
DESCRIPTION
|
||||
Rsx_Storage provides safe, scoped access to browser sessionStorage and
|
||||
localStorage with automatic handling of unavailable storage, quota exceeded
|
||||
errors, and scope invalidation.
|
||||
|
||||
Key features:
|
||||
- Automatic scoping by session, user, site, and build version
|
||||
- Graceful degradation when storage unavailable (returns null)
|
||||
- Automatic quota management (clears and retries when full)
|
||||
- Scope validation (clears stale data on scope change)
|
||||
- Developer-friendly key format for easy inspection
|
||||
|
||||
SCOPING SYSTEM
|
||||
All storage keys are automatically scoped to prevent data leakage between:
|
||||
- Different sessions (window.rsxapp.session_hash - hashed, non-reversible)
|
||||
- Different users (window.rsxapp.user.id)
|
||||
- Different sites (window.rsxapp.site.id)
|
||||
- Different builds (window.rsxapp.build_key)
|
||||
|
||||
The scope is calculated by combining these values into a suffix:
|
||||
session_hash_user_id_site_id_build_key
|
||||
|
||||
This scope is stored in the special key `_rsx_scope_key`. On page load, if
|
||||
this key doesn't exist or doesn't match the current scope, all RSpade keys
|
||||
are cleared and the new scope is stored.
|
||||
|
||||
The session_hash is a server-generated HMAC hash of the session cookie using
|
||||
the application's encryption key, making it non-reversible while maintaining
|
||||
consistency per session.
|
||||
|
||||
Example scope suffix:
|
||||
a1b2c3d4e5f6_42_1_v2.1.0
|
||||
└─ session ──┘ │ │ └─ build
|
||||
user │
|
||||
site
|
||||
|
||||
KEY FORMAT
|
||||
Keys are stored with an `rsx::` namespace prefix, followed by the developer
|
||||
key, followed by the scope suffix:
|
||||
|
||||
rsx::developer_key::scope_suffix
|
||||
|
||||
Example:
|
||||
rsx::flash_queue::abc123def456_42_1_v2.1.0
|
||||
|
||||
The `rsx::` prefix serves two purposes:
|
||||
1. Identifies RSpade keys for safe selective clearing (scope changes, quota)
|
||||
2. Prevents collisions with other JavaScript libraries
|
||||
|
||||
This format allows developers to easily identify keys in browser developer
|
||||
tools while maintaining proper scoping and coexistence with third-party
|
||||
libraries. When inspecting storage, you'll see the `rsx::` prefix, your
|
||||
original key name, and the scope suffix.
|
||||
|
||||
STORAGE AVAILABILITY
|
||||
Rsx_Storage automatically detects if sessionStorage or localStorage are
|
||||
available. Storage may be unavailable due to:
|
||||
|
||||
- Private browsing mode (some browsers block storage)
|
||||
- Browser security settings
|
||||
- Storage quota set to 0
|
||||
- Browser bugs or incompatibilities
|
||||
|
||||
When storage is unavailable:
|
||||
- set() operations are silently ignored (no error)
|
||||
- get() operations return null
|
||||
- remove() operations are silently ignored
|
||||
|
||||
This allows the application to continue functioning even when storage is
|
||||
unavailable, as long as the stored data is non-critical.
|
||||
|
||||
SIZE LIMIT
|
||||
Individual values larger than 1 MB are automatically rejected and not stored.
|
||||
|
||||
When attempting to store data > 1 MB:
|
||||
- Operation is silently skipped (no error thrown)
|
||||
- Console warning logged with actual size
|
||||
- get() will return null for that key
|
||||
|
||||
This prevents quota issues and ensures browser storage remains performant.
|
||||
If you need to store large data, consider:
|
||||
- Storing server-side in database
|
||||
- Using IndexedDB for large client-side data
|
||||
- Splitting data into smaller chunks
|
||||
|
||||
QUOTA EXCEEDED HANDLING
|
||||
When a set() operation fails due to quota exceeded (storage full), Rsx_Storage
|
||||
automatically:
|
||||
|
||||
1. Clears only RSpade keys (keys starting with `rsx::`)
|
||||
2. Preserves other libraries' data
|
||||
3. Restores the _rsx_scope_key
|
||||
4. Retries the set() operation once
|
||||
|
||||
If the retry also fails, the error is logged and the operation is abandoned.
|
||||
|
||||
This ensures the application continues functioning even when storage is full,
|
||||
and minimizes impact on other JavaScript libraries sharing the same storage.
|
||||
Only RSpade's previously stored data will be lost.
|
||||
|
||||
SCOPE INVALIDATION
|
||||
Storage is automatically validated before every write operation. When the scope
|
||||
changes, only RSpade keys (starting with `rsx::`) are cleared, preserving other
|
||||
libraries' data.
|
||||
|
||||
Scope changes occur when:
|
||||
|
||||
- User logs in/out (user ID changes)
|
||||
- User switches sites (site ID changes)
|
||||
- Application is updated (build key changes)
|
||||
- Session changes (rsx cookie changes)
|
||||
|
||||
This prevents stale data from one context bleeding into another context.
|
||||
|
||||
Example: User A logs in, stores preferences, logs out. User B logs in on the
|
||||
same browser. User B sees clean RSpade storage, not User A's data. Other
|
||||
libraries' data (e.g., analytics cookies, third-party preferences) remains
|
||||
intact.
|
||||
|
||||
VOLATILE STORAGE WARNING
|
||||
Browser storage is VOLATILE and can be cleared at any time. Never store data
|
||||
critical to application functionality.
|
||||
|
||||
Storage can be lost due to:
|
||||
- User clearing browser data
|
||||
- Private browsing mode restrictions
|
||||
- Quota exceeded errors (automatic clear + retry)
|
||||
- Scope changes (logout, build update, session change)
|
||||
- Browser storage unavailable
|
||||
- Storage corruption or browser bugs
|
||||
|
||||
ONLY store data that is:
|
||||
- Cached for performance (can be re-fetched)
|
||||
- UI convenience state (non-essential)
|
||||
- Transient messages (flash alerts, notifications)
|
||||
|
||||
If data is REQUIRED for the application to function correctly, store it
|
||||
server-side in the database or PHP session.
|
||||
|
||||
EXAMPLES
|
||||
Cache API response data (sessionStorage)
|
||||
// Store cached data
|
||||
const users = await fetch_users();
|
||||
Rsx_Storage.session_set('cached_users', users);
|
||||
|
||||
// Retrieve cached data
|
||||
let users = Rsx_Storage.session_get('cached_users');
|
||||
if (!users) {
|
||||
users = await fetch_users();
|
||||
Rsx_Storage.session_set('cached_users', users);
|
||||
}
|
||||
|
||||
Persist UI preferences (localStorage)
|
||||
// Save theme preference
|
||||
Rsx_Storage.local_set('theme', 'dark');
|
||||
|
||||
// Load theme preference
|
||||
const theme = Rsx_Storage.local_get('theme') || 'light';
|
||||
apply_theme(theme);
|
||||
|
||||
Store flash alert queue (sessionStorage)
|
||||
// Queue persists across page navigation
|
||||
Rsx_Storage.session_set('flash_queue', messages);
|
||||
|
||||
// Restore queue on next page
|
||||
const messages = Rsx_Storage.session_get('flash_queue') || [];
|
||||
|
||||
Complex data structures
|
||||
// Automatically JSON serialized
|
||||
Rsx_Storage.local_set('user_prefs', {
|
||||
theme: 'dark',
|
||||
sidebar_collapsed: true,
|
||||
recent_items: [1, 2, 3]
|
||||
});
|
||||
|
||||
// Automatically JSON parsed
|
||||
const prefs = Rsx_Storage.local_get('user_prefs');
|
||||
console.log(prefs.theme); // 'dark'
|
||||
|
||||
Handling unavailable storage
|
||||
// Always check for null (storage unavailable or key not found)
|
||||
const cached = Rsx_Storage.session_get('data');
|
||||
if (cached) {
|
||||
use_cached_data(cached);
|
||||
} else {
|
||||
fetch_fresh_data();
|
||||
}
|
||||
|
||||
Removing stale data
|
||||
// Clean up when no longer needed
|
||||
Rsx_Storage.session_remove('temp_data');
|
||||
|
||||
SESSIONSTORAGE VS LOCALSTORAGE
|
||||
sessionStorage:
|
||||
- Cleared when tab/window closes
|
||||
- Not shared across tabs/windows
|
||||
- Use for: temporary data, current session state, flash messages
|
||||
|
||||
localStorage:
|
||||
- Persists across browser sessions
|
||||
- Shared across all tabs/windows of same origin
|
||||
- Use for: user preferences, cached data, long-term UI state
|
||||
|
||||
Both are scoped identically by Rsx_Storage (cookie, user, site, build).
|
||||
|
||||
MIGRATION FROM NATIVE STORAGE
|
||||
If you're currently using sessionStorage/localStorage directly:
|
||||
|
||||
Before:
|
||||
sessionStorage.setItem('my_key', JSON.stringify(data));
|
||||
const data = JSON.parse(sessionStorage.getItem('my_key'));
|
||||
sessionStorage.removeItem('my_key');
|
||||
|
||||
After:
|
||||
Rsx_Storage.session_set('my_key', data);
|
||||
const data = Rsx_Storage.session_get('my_key');
|
||||
Rsx_Storage.session_remove('my_key');
|
||||
|
||||
Benefits:
|
||||
- No manual JSON.stringify/parse
|
||||
- Automatic scoping (prevents data leakage)
|
||||
- Graceful handling of unavailable storage
|
||||
- Automatic quota management
|
||||
- Scope validation
|
||||
|
||||
DEBUGGING
|
||||
Inspect storage in browser developer tools:
|
||||
|
||||
1. Open Developer Tools (F12)
|
||||
2. Navigate to Application tab (Chrome) or Storage tab (Firefox)
|
||||
3. Expand Session Storage or Local Storage
|
||||
4. Look for keys matching: rsx::your_key::scope_suffix
|
||||
|
||||
The _rsx_scope_key shows the current active scope.
|
||||
All RSpade keys are prefixed with `rsx::` for easy identification.
|
||||
|
||||
Console logging:
|
||||
- Scope changes: "[Rsx_Storage] Scope changed, clearing RSpade keys only"
|
||||
- First use: "[Rsx_Storage] Initializing scope (first use)"
|
||||
- Quota exceeded: "[Rsx_Storage] Quota exceeded, clearing RSpade keys"
|
||||
- Keys cleared: "[Rsx_Storage] Cleared X RSpade keys"
|
||||
- Errors: "[Rsx_Storage] Failed to..."
|
||||
|
||||
FILE LOCATION
|
||||
/system/app/RSpade/Core/Js/Rsx_Storage.js
|
||||
|
||||
SEE ALSO
|
||||
Flash Alert system (uses Rsx_Storage for queue persistence)
|
||||
php artisan rsx:man flash_alert
|
||||
|
||||
RSPADE January 2025 RSX_STORAGE(1)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user