Fix code quality violations and enhance ROUTE-EXISTS-01 rule

Implement JQHTML function cache ID system and fix bundle compilation
Implement underscore prefix for system tables
Fix JS syntax linter to support decorators and grant exception to Task system
SPA: Update planning docs and wishlists with remaining features
SPA: Document Navigation API abandonment and future enhancements
Implement SPA browser integration with History API (Phase 1)
Convert contacts view page to SPA action
Convert clients pages to SPA actions and document conversion procedure
SPA: Merge GET parameters and update documentation
Implement SPA route URL generation in JavaScript and PHP
Implement SPA bootstrap controller architecture
Add SPA routing manual page (rsx:man spa)
Add SPA routing documentation to CLAUDE.md
Phase 4 Complete: Client-side SPA routing implementation
Update get_routes() consumers for unified route structure
Complete SPA Phase 3: PHP-side route type detection and is_spa flag
Restore unified routes structure and Manifest_Query class
Refactor route indexing and add SPA infrastructure
Phase 3 Complete: SPA route registration in manifest
Implement SPA Phase 2: Extract router code and test decorators
Rename Jqhtml_Component to Component and complete SPA foundation setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-19 17:48:15 +00:00
parent 77b4d10af8
commit 9ebcc359ae
4360 changed files with 37751 additions and 18578 deletions

View File

@@ -186,6 +186,11 @@ class Ajax {
});
}
// Handle flash_alerts from server
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
Server_Side_Flash.process(response.flash_alerts);
}
// Check if the response was successful
if (response._success === true) {
// @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value

View File

@@ -249,7 +249,7 @@ class Debugger {
Debugger._console_timer = null;
try {
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_console_messages'), { messages: messages });
return Ajax.call(Rsx.Route('Debugger_Controller::log_console_messages'), { messages: messages });
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send console_debug messages to server:', error);
@@ -270,7 +270,7 @@ class Debugger {
Debugger._error_batch_count++;
try {
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_browser_errors'), { errors: errors });
return Ajax.call(Rsx.Route('Debugger_Controller::log_browser_errors'), { errors: errors });
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send browser errors to server:', error);

View File

@@ -1,470 +0,0 @@
/**
* Form utilities for validation and error handling
*/
class Form_Utils {
/**
* Framework initialization hook to register jQuery plugin
* Creates $.fn.ajax_submit() for form elements
* @private
*/
static _on_framework_core_define(params = {}) {
$.fn.ajax_submit = function(options = {}) {
const $element = $(this);
if (!$element.is('form')) {
throw new Error('ajax_submit() can only be called on form elements');
}
const url = $element.attr('action');
if (!url) {
throw new Error('Form must have an action attribute');
}
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
return Form_Utils.ajax_submit($element, controller, action, options);
};
}
/**
* Shows form validation errors
*
* REQUIRED HTML STRUCTURE:
* For inline field errors to display properly, form fields must follow this structure:
*
* <div class="form-group">
* <label class="form-label" for="field-name">Field Label</label>
* <input class="form-control" id="field-name" name="field-name" type="text">
* </div>
*
* Key requirements:
* - Wrap each field in a container with class "form-group" (or "form-check" / "input-group")
* - Input must have a "name" attribute matching the error key
* - Use "form-control" class on inputs for Bootstrap 5 styling
*
* Accepts three formats:
* - String: Single error shown as alert
* - Array of strings: Multiple errors shown as bulleted alert
* - Object: Field names mapped to errors, shown inline (unmatched shown as alert)
*
* @param {string} parent_selector - jQuery selector for parent element
* @param {string|Object|Array} errors - Error messages to display
* @returns {Promise} Promise that resolves when all animations complete
*/
static apply_form_errors(parent_selector, errors) {
console.error(errors);
const $parent = $(parent_selector);
// Reset the form errors before applying new ones
Form_Utils.reset_form_errors(parent_selector);
// Normalize input to standard format
const normalized = Form_Utils._normalize_errors(errors);
return new Promise((resolve) => {
let animations = [];
if (normalized.type === 'string') {
// Single error message
animations = Form_Utils._apply_general_errors($parent, normalized.data);
} else if (normalized.type === 'array') {
// Array of error messages
const deduplicated = Form_Utils._deduplicate_errors(normalized.data);
animations = Form_Utils._apply_general_errors($parent, deduplicated);
} else if (normalized.type === 'fields') {
// Field-specific errors
const result = Form_Utils._apply_field_errors($parent, normalized.data);
animations = result.animations;
// Count matched fields
const matched_count = Object.keys(normalized.data).length - Object.keys(result.unmatched).length;
const unmatched_deduplicated = Form_Utils._deduplicate_errors(result.unmatched);
const unmatched_count = Object.keys(unmatched_deduplicated).length;
// Show summary alert if there are any field errors (matched or unmatched)
if (matched_count > 0 || unmatched_count > 0) {
// Build summary message
let summary_msg = '';
if (matched_count > 0) {
summary_msg = matched_count === 1
? 'Please correct the error highlighted below.'
: 'Please correct the errors highlighted below.';
}
// If there are unmatched errors, add them as a bulleted list
if (unmatched_count > 0) {
const summary_animations = Form_Utils._apply_combined_error($parent, summary_msg, unmatched_deduplicated);
animations.push(...summary_animations);
} else {
// Just the summary message, no unmatched errors
const summary_animations = Form_Utils._apply_general_errors($parent, summary_msg);
animations.push(...summary_animations);
}
}
}
// Resolve the promise once all animations are complete
Promise.all(animations).then(() => {
// Scroll to error container if it exists
const $error_container = $parent.find('[data-id="error_container"]').first();
if ($error_container.length > 0) {
const container_top = $error_container.offset().top;
// Calculate fixed header offset
const fixed_header_height = Form_Utils._get_fixed_header_height();
// Scroll to position error container 20px below any fixed headers
const target_scroll = container_top - fixed_header_height - 20;
$('html, body').animate({
scrollTop: target_scroll
}, 500);
}
resolve();
});
});
}
/**
* Clears form validation errors and resets all form values to defaults
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
*/
static reset(form_selector) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
Form_Utils.reset_form_errors(form_selector);
$form.trigger('reset');
}
/**
* Serializes form data into key-value object
* Returns all input elements with name attributes as object properties
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
* @returns {Object} Form data as key-value pairs
*/
static serialize(form_selector) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
const data = {};
$form.serializeArray().forEach((item) => {
data[item.name] = item.value;
});
return data;
}
/**
* Submits form to RSX controller action via AJAX
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
* @param {string} controller - Controller class name (e.g., 'User_Controller')
* @param {string} action - Action method name (e.g., 'save_profile')
* @param {Object} options - Optional configuration {on_success: fn, on_error: fn}
* @returns {Promise} Promise that resolves with response data
*/
static async ajax_submit(form_selector, controller, action, options = {}) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
const form_data = Form_Utils.serialize($form);
Form_Utils.reset_form_errors(form_selector);
try {
const response = await Ajax.call(controller, action, form_data);
if (options.on_success) {
options.on_success(response);
}
return response;
} catch (error) {
if (error.type === 'form_error' && error.details) {
await Form_Utils.apply_form_errors(form_selector, error.details);
} else {
await Form_Utils.apply_form_errors(form_selector, error.message || 'An error occurred');
}
if (options.on_error) {
options.on_error(error);
}
throw error;
}
}
/**
* Removes form validation errors
* @param {string} parent_selector - jQuery selector for parent element
*/
static reset_form_errors(parent_selector) {
const $parent = $(parent_selector);
// Remove flash messages
$('.flash-messages').remove();
// Remove alert-danger messages
$parent.find('.alert-danger').remove();
// Remove validation error classes and text from form elements
$parent.find('.is-invalid').removeClass('is-invalid');
$parent.find('.invalid-feedback').remove();
}
// ------------------------
/**
* Normalizes error input into standard formats
* @param {string|Object|Array} errors - Raw error input
* @returns {Object} Normalized errors as {type: 'string'|'array'|'fields', data: ...}
* @private
*/
static _normalize_errors(errors) {
// Handle null/undefined
if (!errors) {
return { type: 'string', data: 'An error has occurred' };
}
// Handle string
if (typeof errors === 'string') {
return { type: 'string', data: errors };
}
// Handle array
if (Array.isArray(errors)) {
// Array of strings - general errors
if (errors.every((e) => typeof e === 'string')) {
return { type: 'array', data: errors };
}
// Array with object as first element - extract it
if (errors.length > 0 && typeof errors[0] === 'object') {
return Form_Utils._normalize_errors(errors[0]);
}
// Empty or mixed array
return { type: 'array', data: [] };
}
// Handle object - check for Laravel response wrapper
if (typeof errors === 'object') {
// Unwrap {errors: {...}} or {error: {...}}
const unwrapped = errors.errors || errors.error;
if (unwrapped) {
return Form_Utils._normalize_errors(unwrapped);
}
// Convert Laravel validator format {field: [msg1, msg2]} to {field: msg1}
const normalized = {};
for (const field in errors) {
if (errors.hasOwnProperty(field)) {
const value = errors[field];
if (Array.isArray(value) && value.length > 0) {
normalized[field] = value[0];
} else if (typeof value === 'string') {
normalized[field] = value;
} else {
normalized[field] = String(value);
}
}
}
return { type: 'fields', data: normalized };
}
// Final catch-all*
return { type: 'string', data: String(errors) };
}
/**
* Removes duplicate error messages from array or object values
* @param {Array|Object} errors - Errors to deduplicate
* @returns {Array|Object} Deduplicated errors
* @private
*/
static _deduplicate_errors(errors) {
if (Array.isArray(errors)) {
return [...new Set(errors)];
}
if (typeof errors === 'object') {
const seen = new Set();
const result = {};
for (const key in errors) {
const value = errors[key];
if (!seen.has(value)) {
seen.add(value);
result[key] = value;
}
}
return result;
}
return errors;
}
/**
* Applies field-specific validation errors to form inputs
* @param {jQuery} $parent - Parent element containing form
* @param {Object} field_errors - Object mapping field names to error messages
* @returns {Object} Object containing {animations: Array, unmatched: Object}
* @private
*/
static _apply_field_errors($parent, field_errors) {
const animations = [];
const unmatched = {};
for (const field_name in field_errors) {
const error_message = field_errors[field_name];
const $input = $parent.find(`[name="${field_name}"]`);
if (!$input.length) {
unmatched[field_name] = error_message;
continue;
}
const $error = $('<div class="invalid-feedback"></div>').html(error_message);
const $target = $input.closest('.form-group, .form-check, .input-group');
if (!$target.length) {
unmatched[field_name] = error_message;
continue;
}
$input.addClass('is-invalid');
$error.appendTo($target);
animations.push($error.hide().fadeIn(300).promise());
}
return { animations, unmatched };
}
/**
* Applies combined error message with summary and unmatched field errors
* @param {jQuery} $parent - Parent element containing form
* @param {string} summary_msg - Summary message (e.g., "Please correct the errors below")
* @param {Object} unmatched_errors - Object of field errors that couldn't be matched to fields
* @returns {Array} Array of animation promises
* @private
*/
static _apply_combined_error($parent, summary_msg, unmatched_errors) {
const animations = [];
const $error_container = $parent.find('[data-id="error_container"]').first();
const $target = $error_container.length > 0 ? $error_container : $parent;
// Create alert with summary message and bulleted list of unmatched errors
const $alert = $('<div class="alert alert-danger" role="alert"></div>');
// Add summary message if provided
if (summary_msg) {
$('<p class="mb-2"></p>').text(summary_msg).appendTo($alert);
}
// Add unmatched errors as bulleted list
if (Object.keys(unmatched_errors).length > 0) {
const $list = $('<ul class="mb-0"></ul>');
for (const field_name in unmatched_errors) {
const error_msg = unmatched_errors[field_name];
$('<li></li>').html(error_msg).appendTo($list);
}
$list.appendTo($alert);
}
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
return animations;
}
/**
* Applies general error messages as alert box
* @param {jQuery} $parent - Parent element to prepend alert to
* @param {string|Array} messages - Error message(s) to display
* @returns {Array} Array of animation promises
* @private
*/
static _apply_general_errors($parent, messages) {
const animations = [];
// Look for a specific error container div (e.g., in Rsx_Form component)
const $error_container = $parent.find('[data-id="error_container"]').first();
const $target = $error_container.length > 0 ? $error_container : $parent;
if (typeof messages === 'string') {
// Single error - simple alert without list
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
} else if (Array.isArray(messages) && messages.length > 0) {
// Multiple errors - bulleted list
const $alert = $('<div class="alert alert-danger" role="alert"><ul class="mb-0"></ul></div>');
const $list = $alert.find('ul');
messages.forEach((msg) => {
const text = (msg + '').trim() || 'An error has occurred';
$('<li></li>').html(text).appendTo($list);
});
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
} else if (typeof messages === 'object' && !Array.isArray(messages)) {
// Object of unmatched field errors - convert to array
const error_list = Object.values(messages)
.map((v) => String(v).trim())
.filter((v) => v);
if (error_list.length > 0) {
return Form_Utils._apply_general_errors($parent, error_list);
}
}
return animations;
}
/**
* Calculates the total height of fixed/sticky headers at the top of the page
* @returns {number} Total height in pixels of fixed top elements
* @private
*/
static _get_fixed_header_height() {
let total_height = 0;
// Find all fixed or sticky positioned elements
$('*').each(function() {
const $el = $(this);
const position = $el.css('position');
// Only check fixed or sticky elements
if (position !== 'fixed' && position !== 'sticky') {
return;
}
// Check if element is positioned at or near the top
const top = parseInt($el.css('top')) || 0;
if (top > 50) {
return; // Not a top header
}
// Check if element is visible
if (!$el.is(':visible')) {
return;
}
// Check if element spans significant width (likely a header/navbar)
const width = $el.outerWidth();
const viewport_width = $(window).width();
if (width < viewport_width * 0.5) {
return; // Too narrow to be a header
}
// Add this element's height
total_height += $el.outerHeight();
});
return total_height;
}
}

View File

@@ -37,7 +37,7 @@
* if (Rsx.is_dev()) { console.log('Development mode'); }
*
* // Route generation
* const url = Rsx.Route('Controller', 'action').url();
* const url = Rsx.Route('Controller::action').url();
*
* // Unique IDs
* const uniqueId = Rsx.uid(); // e.g., "rsx_1234567890_1"
@@ -134,70 +134,99 @@ class Rsx {
static _routes = {};
/**
* Define routes from bundled data
* Called by generated JavaScript in bundles
* Calculate scope key from current environment.
*
* Scope key is a hash key which includes the current value of the session, user, site, and build keys.
* Data hashed with this key will be scoped to the current logged in user, and will be invalidated if
* the user logs out or the application source code is updated / redeployed / etc.
*
* @returns {string}
* @private
*/
static _define_routes(routes) {
// Merge routes into the global route storage
for (const class_name in routes) {
if (!Rsx._routes[class_name]) {
Rsx._routes[class_name] = {};
}
for (const method_name in routes[class_name]) {
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
}
static scope_key() {
const parts = [];
// Get session hash (hashed on server for non-reversible scoping)
if (window.rsxapp?.session_hash) {
parts.push(window.rsxapp.session_hash);
}
// Get user ID
if (window.rsxapp?.user?.id) {
parts.push(window.rsxapp.user.id);
}
// Get site ID
if (window.rsxapp?.site?.id) {
parts.push(window.rsxapp.site.id);
}
// Get build key
if (window.rsxapp?.build_key) {
parts.push(window.rsxapp.build_key);
}
return parts.join('_');
}
/**
* Generate URL for a controller route
* Generate URL for a controller route or SPA action
*
* This method generates URLs for controller actions by looking up route patterns
* and replacing parameters. It handles both regular routes and Ajax endpoints.
* This method generates URLs by looking up route patterns and replacing parameters.
* It handles controller routes, SPA action routes, and Ajax endpoints.
*
* If the route is not found in the route definitions, a default pattern is used:
* `/_/{controller}/{action}` with all parameters appended as query strings.
*
* Usage examples:
* ```javascript
* // Simple route without parameters (defaults to 'index' action)
* // Controller route (defaults to 'index' method)
* const url = Rsx.Route('Frontend_Index_Controller');
* // Returns: /dashboard
*
* // Route with explicit action
* const url = Rsx.Route('Frontend_Index_Controller', 'index');
* // Returns: /dashboard
*
* // Route with integer parameter (sets 'id')
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', 123);
* // Controller route with explicit method
* const url = Rsx.Route('Frontend_Client_View_Controller::view', 123);
* // Returns: /clients/view/123
*
* // SPA action route
* const url = Rsx.Route('Contacts_Index_Action');
* // Returns: /contacts
*
* // Route with integer parameter (sets 'id')
* const url = Rsx.Route('Contacts_View_Action', 123);
* // Returns: /contacts/123
*
* // Route with named parameters (object)
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {id: 'C001'});
* // Returns: /clients/view/C001
* const url = Rsx.Route('Contacts_View_Action', {id: 'C001'});
* // Returns: /contacts/C001
*
* // Route with required and query parameters
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {
* const url = Rsx.Route('Contacts_View_Action', {
* id: 'C001',
* tab: 'history'
* });
* // Returns: /clients/view/C001?tab=history
*
* // Route not found - uses default pattern
* const url = Rsx.Route('Unimplemented_Controller', 'some_action', {foo: 'bar'});
* // Returns: /_/Unimplemented_Controller/some_action?foo=bar
* // Returns: /contacts/C001?tab=history
*
* // Placeholder route
* const url = Rsx.Route('Future_Controller', '#index');
* const url = Rsx.Route('Future_Controller::#index');
* // Returns: #
* ```
*
* @param {string} class_name The controller class name (e.g., 'User_Controller')
* @param {string} [action_name='index'] The action/method name (defaults to 'index'). Use '#action' for placeholders.
* @param {string} action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
* @param {number|Object} [params=null] Route parameters. Integer sets 'id', object provides named params.
* @returns {string} The generated URL
*/
static Route(class_name, action_name = 'index', params = null) {
static Route(action, params = null) {
// Parse action into class_name and action_name
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
let class_name, action_name;
if (action.includes('::')) {
[class_name, action_name] = action.split('::', 2);
} else {
class_name = action;
action_name = 'index';
}
// Normalize params to object
let params_obj = {};
if (typeof params === 'number') {
@@ -213,13 +242,19 @@ class Rsx {
return '#';
}
// Check if route exists in definitions
// Check if route exists in PHP controller definitions
let pattern;
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
pattern = Rsx._routes[class_name][action_name];
} else {
// Route not found - use default pattern /_/{controller}/{action}
pattern = `/_/${class_name}/${action_name}`;
// Not found in PHP routes - check if it's a SPA action
pattern = Rsx._try_spa_action_route(class_name);
if (!pattern) {
// Route not found - use default pattern /_/{controller}/{action}
// For SPA actions, action_name defaults to 'index'
pattern = `/_/${class_name}/${action_name}`;
}
}
// Generate URL from pattern
@@ -287,6 +322,60 @@ class Rsx {
return url;
}
/**
* Try to find a route pattern for a SPA action class
* Returns the route pattern or null if not found
*
* @param {string} class_name The action class name
* @returns {string|null} The route pattern or null
*/
static _try_spa_action_route(class_name) {
// Get all classes from manifest
const all_classes = Manifest.get_all_classes();
// Find the class by name
for (const class_info of all_classes) {
if (class_info.class_name === class_name) {
const class_object = class_info.class_object;
// Check if it's a SPA action (has Spa_Action in prototype chain)
if (typeof Spa_Action !== 'undefined' &&
class_object.prototype instanceof Spa_Action) {
// Get route patterns from decorator metadata
const routes = class_object._spa_routes || [];
if (routes.length > 0) {
// Return the first route pattern
return routes[0];
}
}
// Found the class but it's not a SPA action or has no routes
return null;
}
}
// Class not found
return null;
}
/**
* Define routes from bundled data
* Called by generated JavaScript in bundles
*/
static _define_routes(routes) {
// Merge routes into the global route storage
for (const class_name in routes) {
if (!Rsx._routes[class_name]) {
Rsx._routes[class_name] = {};
}
for (const method_name in routes[class_name]) {
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
}
}
}
/**
* Internal: Call a specific method on all classes that have it
* Collects promises from return values and waits for all to resolve
@@ -450,17 +539,17 @@ class Rsx {
}
/**
* Get all page state from URL hash
* Get all hash state from URL hash
*
* Usage:
* ```javascript
* const state = Rsx.get_all_page_state();
* const state = Rsx.url_hash_get_all();
* // Returns: {dg_page: '2', dg_sort: 'name'}
* ```
*
* @returns {Object} All hash parameters as key-value pairs
*/
static get_all_page_state() {
static url_hash_get_all() {
return Rsx._parse_hash();
}
@@ -469,14 +558,14 @@ class Rsx {
*
* Usage:
* ```javascript
* const page = Rsx.get_page_state('dg_page');
* const page = Rsx.url_hash_get('dg_page');
* // Returns: '2' or null if not set
* ```
*
* @param {string} key The key to retrieve
* @returns {string|null} The value or null if not found
*/
static get_page_state(key) {
static url_hash_get(key) {
const state = Rsx._parse_hash();
return state[key] ?? null;
}
@@ -486,16 +575,16 @@ class Rsx {
*
* Usage:
* ```javascript
* Rsx.set_page_state('dg_page', 2);
* Rsx.url_hash_set_single('dg_page', 2);
* // URL becomes: http://example.com/page#dg_page=2
*
* Rsx.set_page_state('dg_page', null); // Remove key
* Rsx.url_hash_set_single('dg_page', null); // Remove key
* ```
*
* @param {string} key The key to set
* @param {string|number|null} value The value (null/empty removes the key)
*/
static set_page_state(key, value) {
static url_hash_set_single(key, value) {
const state = Rsx._parse_hash();
// Update or remove the key
@@ -516,13 +605,15 @@ class Rsx {
*
* Usage:
* ```javascript
* Rsx.set_all_page_state({dg_page: 2, dg_sort: 'name'});
* Rsx.url_hash_set({dg_page: 2, dg_sort: 'name'});
* // URL becomes: http://example.com/page#dg_page=2&dg_sort=name
*
* Rsx.url_hash_set({dg_page: null}); // Remove key from hash
* ```
*
* @param {Object} new_state Object with key-value pairs to set
* @param {Object} new_state Object with key-value pairs to set (null removes key)
*/
static set_all_page_state(new_state) {
static url_hash_set(new_state) {
const state = Rsx._parse_hash();
// Merge new state
@@ -601,7 +692,7 @@ class Rsx {
<div class="alert alert-warning" role="alert">
<h5>Validation Errors:</h5>
<ul class="mb-0">
${error_list.map(err => `<li>${Rsx._escape_html(err)}</li>`).join('')}
${error_list.map((err) => `<li>${Rsx._escape_html(err)}</li>`).join('')}
</ul>
</div>
`;

357
app/RSpade/Core/Js/Rsx_Storage.js Executable file
View File

@@ -0,0 +1,357 @@
/**
* Rsx_Storage - Scoped browser storage helper with graceful degradation
*
* Provides safe, scoped access to sessionStorage and localStorage with automatic
* handling of unavailable storage, quota exceeded errors, and scope invalidation.
*
* Key Features:
* - **Automatic scoping**: All keys scoped by cookie, user, site, and build version
* - **Graceful degradation**: Returns null when storage unavailable (private browsing, etc.)
* - **Quota management**: Auto-clears storage when full and retries operation
* - **Scope validation**: Clears storage when scope changes (user logout, build update, etc.)
* - **Developer-friendly keys**: Scoped suffix allows easy inspection in dev tools
*
* Scoping Strategy:
* Storage is scoped by combining:
* - `window.rsxapp.session_hash` (hashed session identifier, non-reversible)
* - `window.rsxapp.user.id` (current user)
* - `window.rsxapp.site.id` (current site)
* - `window.rsxapp.build_key` (application version)
*
* This scope is stored in `_rsx_scope_key`. If the scope changes between page loads,
* all RSpade keys are cleared to prevent stale data from different sessions/users/builds.
*
* Key Format:
* Keys are stored as: `rsx::developer_key::scope_suffix`
* Example: `rsx::flash_queue::abc123_42_1_v2.1.0`
*
* The `rsx::` prefix identifies RSpade keys, allowing safe clearing of only our keys
* without affecting other JavaScript libraries. This enables transparent coexistence
* with third-party libraries that also use browser storage.
*
* Quota Exceeded Handling:
* When storage quota is exceeded during a set operation, only RSpade keys (prefixed with
* `rsx::`) are cleared, preserving other libraries' data. The operation is then retried
* once. This ensures the application continues functioning even when storage is full.
*
* Usage:
* // Session storage (cleared on tab close)
* Rsx_Storage.session_set('user_preferences', {theme: 'dark'});
* const prefs = Rsx_Storage.session_get('user_preferences');
* Rsx_Storage.session_remove('user_preferences');
*
* // Local storage (persists across sessions)
* Rsx_Storage.local_set('cached_data', {items: [...]});
* const data = Rsx_Storage.local_get('cached_data');
* Rsx_Storage.local_remove('cached_data');
*
* IMPORTANT - Volatile Storage:
* Storage can be cleared at any time due to:
* - User clearing browser data
* - Private browsing mode restrictions
* - Quota exceeded errors
* - Scope changes (logout, build update, session change)
* - Browser storage unavailable
*
* Therefore, NEVER store critical data that impacts application functionality.
* Only store:
* - Cached data (performance optimization)
* - UI state (convenience, not required)
* - Transient messages (flash alerts, notifications)
*
* If the data is required for the application to function, store it server-side.
*/
class Rsx_Storage {
static _scope_suffix = null;
static _session_available = null;
static _local_available = null;
/**
* Initialize storage system and validate scope
* Called automatically on first access
* @private
*/
static _init() {
// Check if already initialized
if (this._scope_suffix !== null) {
return;
}
// Check storage availability
this._session_available = this._is_storage_available('sessionStorage');
this._local_available = this._is_storage_available('localStorage');
// Calculate current scope suffix
this._scope_suffix = Rsx.scope_key();
// Validate scope for both storages
if (this._session_available) {
this._validate_scope(sessionStorage, 'session');
}
if (this._local_available) {
this._validate_scope(localStorage, 'local');
}
}
/**
* Check if a storage type is available
* @param {string} type - 'sessionStorage' or 'localStorage'
* @returns {boolean}
* @private
*/
static _is_storage_available(type) {
try {
const storage = window[type];
const test = '__rsx_storage_test__';
storage.setItem(test, test);
storage.removeItem(test);
return true;
} catch (e) {
return false;
}
}
/**
* Validate storage scope and clear RSpade keys if changed
* Only clears keys prefixed with 'rsx::' to preserve other libraries' data
* @param {Storage} storage - sessionStorage or localStorage
* @param {string} type - 'session' or 'local' (for logging)
* @private
*/
static _validate_scope(storage, type) {
try {
const stored_scope = storage.getItem('_rsx_scope_key');
// If scope key exists and has changed, clear only RSpade keys
if (stored_scope !== null && stored_scope !== this._scope_suffix) {
console.log(`[Rsx_Storage] Scope changed for ${type}Storage, clearing RSpade keys only:`, {
old_scope: stored_scope,
new_scope: this._scope_suffix,
});
this._clear_rsx_keys(storage);
storage.setItem('_rsx_scope_key', this._scope_suffix);
} else if (stored_scope === null) {
// First time RSpade is using this storage - just set the key, don't clear
console.log(`[Rsx_Storage] Initializing scope for ${type}Storage (first use):`, {
new_scope: this._scope_suffix,
});
storage.setItem('_rsx_scope_key', this._scope_suffix);
}
} catch (e) {
console.error(`[Rsx_Storage] Failed to validate scope for ${type}Storage:`, e);
}
}
/**
* Clear only RSpade keys from storage (keys starting with 'rsx::')
* Preserves keys from other libraries
* @param {Storage} storage - sessionStorage or localStorage
* @private
*/
static _clear_rsx_keys(storage) {
const keys_to_remove = [];
// Collect all RSpade keys
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key && key.startsWith('rsx::')) { // @JS-DEFENSIVE-01-EXCEPTION - Browser API storage.key(i) can return null when i >= storage.length
keys_to_remove.push(key);
}
}
// Remove collected keys
keys_to_remove.forEach((key) => {
try {
storage.removeItem(key);
} catch (e) {
console.error('[Rsx_Storage] Failed to remove key:', key, e);
}
});
console.log(`[Rsx_Storage] Cleared ${keys_to_remove.length} RSpade keys`);
}
/**
* Build scoped key with RSpade namespace prefix
* @param {string} key - Developer-provided key
* @returns {string}
* @private
*/
static _build_key(key) {
return `rsx::${key}::${this._scope_suffix}`;
}
/**
* Set item in sessionStorage
* @param {string} key - Storage key
* @param {*} value - Value to store (will be JSON serialized)
*/
static session_set(key, value) {
this._init();
if (!this._session_available) {
return;
}
this._set_item(sessionStorage, key, value, 'session');
}
/**
* Get item from sessionStorage
* @param {string} key - Storage key
* @returns {*|null} Parsed value or null if not found/unavailable
*/
static session_get(key) {
this._init();
if (!this._session_available) {
return null;
}
return this._get_item(sessionStorage, key);
}
/**
* Remove item from sessionStorage
* @param {string} key - Storage key
*/
static session_remove(key) {
this._init();
if (!this._session_available) {
return;
}
this._remove_item(sessionStorage, key);
}
/**
* Set item in localStorage
* @param {string} key - Storage key
* @param {*} value - Value to store (will be JSON serialized)
*/
static local_set(key, value) {
this._init();
if (!this._local_available) {
return;
}
this._set_item(localStorage, key, value, 'local');
}
/**
* Get item from localStorage
* @param {string} key - Storage key
* @returns {*|null} Parsed value or null if not found/unavailable
*/
static local_get(key) {
this._init();
if (!this._local_available) {
return null;
}
return this._get_item(localStorage, key);
}
/**
* Remove item from localStorage
* @param {string} key - Storage key
*/
static local_remove(key) {
this._init();
if (!this._local_available) {
return;
}
this._remove_item(localStorage, key);
}
/**
* Internal set implementation with scope validation and quota handling
* @param {Storage} storage
* @param {string} key
* @param {*} value
* @param {string} type - 'session' or 'local' (for logging)
* @private
*/
static _set_item(storage, key, value, type) {
// Validate scope before every write
this._validate_scope(storage, type);
const scoped_key = this._build_key(key);
try {
const serialized = JSON.stringify(value);
// Check size - skip if larger than 1MB
const size_bytes = new Blob([serialized]).size;
const size_mb = size_bytes / (1024 * 1024);
if (size_mb > 1) {
console.warn(`[Rsx_Storage] Skipping storage for key "${key}" - data too large (${size_mb.toFixed(2)} MB, limit 1 MB)`);
return;
}
storage.setItem(scoped_key, serialized);
} catch (e) {
// Check if quota exceeded
if (e.name === 'QuotaExceededError' || e.code === 22) {
console.warn(`[Rsx_Storage] Quota exceeded for ${type}Storage, clearing RSpade keys and retrying`);
// Clear only RSpade keys and retry once
this._clear_rsx_keys(storage);
storage.setItem('_rsx_scope_key', this._scope_suffix);
try {
const serialized = JSON.stringify(value);
storage.setItem(scoped_key, serialized);
} catch (retry_error) {
console.error(`[Rsx_Storage] Failed to set item after clearing RSpade keys from ${type}Storage:`, retry_error);
}
} else {
console.error(`[Rsx_Storage] Failed to set item in ${type}Storage:`, e);
}
}
}
/**
* Internal get implementation
* @param {Storage} storage
* @param {string} key
* @returns {*|null}
* @private
*/
static _get_item(storage, key) {
const scoped_key = this._build_key(key);
try {
const serialized = storage.getItem(scoped_key);
if (serialized === null) {
return null;
}
return JSON.parse(serialized);
} catch (e) {
console.error('[Rsx_Storage] Failed to get item:', e);
return null;
}
}
/**
* Internal remove implementation
* @param {Storage} storage
* @param {string} key
* @private
*/
static _remove_item(storage, key) {
const scoped_key = this._build_key(key);
try {
storage.removeItem(scoped_key);
} catch (e) {
console.error('[Rsx_Storage] Failed to remove item:', e);
}
}
}

View File

@@ -459,3 +459,79 @@ function csv_to_array_trim(str_csv) {
});
return ret;
}
// ============================================================================
// URL UTILITIES
// ============================================================================
/**
* Convert a full URL to short URL by removing protocol
*
* Strips http:// or https:// from the beginning of the URL if present.
* Leaves the URL alone if it doesn't start with either protocol.
* Removes trailing slash if there is no path.
*
* @param {string|null} url - URL to convert
* @returns {string|null} Short URL without protocol
*/
function full_url_to_short_url(url) {
if (url === null || url === undefined || url === '') {
return url;
}
// Convert to string if needed
url = String(url);
// Remove http:// or https:// from the beginning (case-insensitive)
if (url.toLowerCase().indexOf('http://') === 0) {
url = url.substring(7);
} else if (url.toLowerCase().indexOf('https://') === 0) {
url = url.substring(8);
}
// Remove trailing slash if there is no path (just domain)
// Check if URL is just domain with trailing slash (no path after slash)
if (url.endsWith('/') && (url.match(/\//g) || []).length === 1) {
url = url.replace(/\/$/, '');
}
return url;
}
/**
* Convert a short URL to full URL by adding protocol
*
* Adds http:// to the beginning of the URL if it lacks a protocol.
* Leaves URLs with existing http:// or https:// unchanged.
* Adds trailing slash if there is no path.
*
* @param {string|null} url - URL to convert
* @returns {string|null} Full URL with protocol
*/
function short_url_to_full_url(url) {
if (url === null || url === undefined || url === '') {
return url;
}
// Convert to string if needed
url = String(url);
let full_url;
// Check if URL already has a protocol (case-insensitive)
if (url.toLowerCase().indexOf('http://') === 0 || url.toLowerCase().indexOf('https://') === 0) {
full_url = url;
} else {
// Add http:// protocol
full_url = 'http://' + url;
}
// Add trailing slash if there is no path (just domain)
// Check if URL has no slash after the domain
const without_protocol = full_url.replace(/^https?:\/\//i, '');
if (without_protocol.indexOf('/') === -1) {
full_url += '/';
}
return full_url;
}