Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

198
app/RSpade/Core/Js/Ajax.js Executable file
View File

@@ -0,0 +1,198 @@
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Client-side Ajax class for making API calls to RSX controllers
*
* Mirrors the PHP Ajax::call (Ajax::internal) functionality for browser-side JavaScript
*/
class Ajax {
/**
* Make an AJAX call to an RSX controller action
*
* All calls are automatically batched using Rsx_Ajax_Batch unless
* window.rsxapp.ajax_disable_batching is true (for debugging).
*
* @param {string} controller - The controller class name (e.g., 'User_Controller')
* @param {string} action - The action method name (e.g., 'get_profile')
* @param {object} params - Parameters to send to the action
* @returns {Promise} - Resolves with the return value, rejects with error
*/
static async call(controller, action, params = {}) {
// Route through batch system
return Rsx_Ajax_Batch.call(controller, action, params);
}
/**
* DEPRECATED: Direct call implementation (preserved for reference)
* This is now handled by Rsx_Ajax_Batch
* @private
*/
static async _call_direct(controller, action, params = {}) {
// Build the endpoint URL
const url = `/_ajax/${controller}/${action}`;
// Log the AJAX call using console_debug
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug('AJAX', `Calling ${controller}.${action}`, params);
}
return new Promise((resolve, reject) => {
$.ajax({
url: url,
method: 'POST',
data: params,
dataType: 'json',
__local_integration: true, // Bypass $.ajax override - this is the official Ajax endpoint pattern
success: (response) => {
// Handle console_debug messages if present
if (response.console_debug && Array.isArray(response.console_debug)) {
response.console_debug.forEach((msg) => {
// Messages must be structured as [channel, [arguments]]
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
// Output with channel as first argument, then spread the arguments
console.log(channel, ...args);
});
}
// Check if the response was successful
if (response.success === true) {
// Process the return value to instantiate any ORM models
const processedValue = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
// Return the processed value
resolve(processedValue);
} else {
// Handle error responses
const error_type = response.error_type || 'unknown_error';
const reason = response.reason || 'Unknown error occurred';
const details = response.details || {};
// Handle specific error types
switch (error_type) {
case 'response_auth_required':
console.error(
'The user is no longer authenticated, this is a placeholder for future code which handles this scenario.'
);
// Create an error object similar to PHP exceptions
const auth_error = new Error(reason);
auth_error.type = 'auth_required';
auth_error.details = details;
reject(auth_error);
break;
case 'response_unauthorized':
console.error(
'The user is unauthorized to perform this action, this is a placeholder for future code which handles this scenario.'
);
const unauth_error = new Error(reason);
unauth_error.type = 'unauthorized';
unauth_error.details = details;
reject(unauth_error);
break;
case 'response_form_error':
// Form validation errors
const form_error = new Error(reason);
form_error.type = 'form_error';
form_error.details = details;
reject(form_error);
break;
case 'response_fatal_error':
// Fatal errors
const fatal_error = new Error(reason);
fatal_error.type = 'fatal_error';
fatal_error.details = details;
// Log to server if browser error logging is enabled
Debugger.log_error({
message: `Ajax Fatal Error: ${reason}`,
type: 'ajax_fatal',
endpoint: url,
details: details,
});
reject(fatal_error);
break;
default:
// Unknown error type
const generic_error = new Error(reason);
generic_error.type = error_type;
generic_error.details = details;
reject(generic_error);
break;
}
}
},
error: (xhr, status, error) => {
// Handle network or server errors
let error_message = 'Network or server error';
if (xhr.responseJSON && xhr.responseJSON.message) {
error_message = xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
error_message = response.message;
}
} catch (e) {
// If response is not JSON, use the status text
error_message = `${status}: ${error}`;
}
} else {
error_message = `${status}: ${error}`;
}
const network_error = new Error(error_message);
network_error.type = 'network_error';
network_error.status = xhr.status;
network_error.statusText = status;
// Log server errors (500+) to the server if browser error logging is enabled
if (xhr.status >= 500) {
Debugger.log_error({
message: `Ajax Server Error ${xhr.status}: ${error_message}`,
type: 'ajax_server_error',
endpoint: url,
status: xhr.status,
statusText: status,
});
}
reject(network_error);
},
});
});
}
/**
* Parses an AJAX URL into controller and action
* @param {string} url - URL in format '/_ajax/Controller_Name/action_name'
* @returns {Object} Object with {controller: string, action: string}
* @throws {Error} If URL doesn't start with /_ajax or has invalid structure
*/
static ajax_url_to_controller_action(url) {
if (!url.startsWith('/_ajax')) {
throw new Error(`URL must start with /_ajax, got: ${url}`);
}
const parts = url.split('/').filter((part) => part !== '');
if (parts.length < 2) {
throw new Error(`Invalid AJAX URL structure: ${url}`);
}
if (parts.length > 3) {
throw new Error(`AJAX URL has too many segments: ${url}`);
}
const controller = parts[1];
const action = parts[2] || 'index';
return { controller, action };
}
}

303
app/RSpade/Core/Js/Debugger.js Executable file
View File

@@ -0,0 +1,303 @@
/**
* Debugger class for console_debug and browser error logging
* Handles batched submission to server when configured
*/
class Debugger {
// Batching state for console_debug messages
static _console_batch = [];
static _console_timer = null;
static _console_batch_count = 0;
// Batching state for error messages
static _error_batch = [];
static _error_timer = null;
static _error_count = 0;
static _error_batch_count = 0;
// Constants
static DEBOUNCE_MS = 2000;
static MAX_ERRORS_PER_PAGE = 20;
static MAX_ERROR_BATCHES = 5;
// Store start time for benchmarking
static _start_time = null;
/**
* Initialize framework error handling
* Called during framework initialization
*/
static _on_framework_core_init() {
// Check if browser error logging is enabled
if (window.rsxapp && window.rsxapp.log_browser_errors) {
// Register global error handler
window.addEventListener('error', function (event) {
Debugger._handle_browser_error({
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error ? event.error.stack : null,
type: 'error',
});
});
// Register unhandled promise rejection handler
window.addEventListener('unhandledrejection', function (event) {
Debugger._handle_browser_error({
message: event.reason ? event.reason.message || String(event.reason) : 'Unhandled promise rejection',
stack: event.reason && event.reason.stack ? event.reason.stack : null,
type: 'unhandledrejection',
});
});
}
// Register ui refresh handler
Rsx.on('refresh', Debugger.on_refresh);
}
// In dev mode, some ui elements can be automatically applied to assist with development
static on_refresh() {
if (!Rsx.is_prod()) {
// Add an underline 2 px blue to all a tags with href === "#" using jquery
// Todo: maybe this should be a configurable debug option?
// $('a[href="#"]').css({
// 'border-bottom': '2px solid blue',
// 'text-decoration': 'none'
// });
}
}
/**
* JavaScript implementation of console_debug
* Mirrors PHP functionality with batching for Laravel log
*/
static console_debug(channel, ...values) {
// Check if console_debug is enabled
if (!window.rsxapp || !window.rsxapp.console_debug || !window.rsxapp.console_debug.enabled) {
return;
}
const config = window.rsxapp.console_debug;
// Normalize channel name
channel = String(channel)
.toUpperCase()
.replace(/[\[\]]/g, '');
// Apply filtering
if (config.filter_mode === 'specific') {
const specific = config.specific_channel;
if (specific) {
// Split comma-separated values and normalize
const channels = specific.split(',').map((c) => c.trim().toUpperCase());
if (!channels.includes(channel)) {
return;
}
}
} else if (config.filter_mode === 'whitelist') {
const whitelist = (config.filter_channels || []).map((c) => c.toUpperCase());
if (!whitelist.includes(channel)) {
return;
}
} else if (config.filter_mode === 'blacklist') {
const blacklist = (config.filter_channels || []).map((c) => c.toUpperCase());
if (blacklist.includes(channel)) {
return;
}
}
// Prepare the message
let message = {
channel: channel,
values: values,
timestamp: new Date().toISOString(),
};
// Add location if configured
if (config.include_location || config.include_backtrace) {
const error = new Error();
const stack = error.stack || '';
const stackLines = stack.split('\n');
if (config.include_location && stackLines.length > 2) {
// Skip Error line and this function
const callerLine = stackLines[2] || '';
const match = callerLine.match(/at\s+.*?\s+\((.*?):(\d+):(\d+)\)/) || callerLine.match(/at\s+(.*?):(\d+):(\d+)/);
if (match) {
message.location = `${match[1]}:${match[2]}`;
}
}
if (config.include_backtrace) {
// Include first 5 stack frames, skipping this function
message.backtrace = stackLines
.slice(2, 7)
.map((line) => line.trim())
.filter((line) => line);
}
}
// Output to browser console if enabled
if (config.outputs && config.outputs.browser) {
const prefix = config.include_benchmark ? `[${Debugger._get_time_prefix()}] ` : '';
const channelPrefix = `[${channel}]`;
// Use appropriate console method based on channel
let consoleMethod = 'log';
if (channel.includes('ERROR')) consoleMethod = 'error';
else if (channel.includes('WARN')) consoleMethod = 'warn';
else if (channel.includes('INFO')) consoleMethod = 'info';
console[consoleMethod](prefix + channelPrefix, ...values);
}
// Batch for Laravel log if enabled
if (config.outputs && config.outputs.laravel_log) {
Debugger._batch_console_message(message);
}
}
/**
* Log an error to the server
* Used manually or by Ajax error handling
*/
static log_error(error) {
// Check if browser error logging is enabled
if (!window.rsxapp || !window.rsxapp.log_browser_errors) {
return;
}
// Normalize error format
let errorData = {};
if (typeof error === 'string') {
errorData.message = error;
errorData.type = 'manual';
} else if (error instanceof Error) {
errorData.message = error.message;
errorData.stack = error.stack;
errorData.type = 'exception';
} else if (error && typeof error === 'object') {
errorData = error;
if (!errorData.type) {
errorData.type = 'manual';
}
}
Debugger._handle_browser_error(errorData);
}
/**
* Internal: Handle browser errors with batching
*/
static _handle_browser_error(errorData) {
// Check limits
if (Debugger._error_count >= Debugger.MAX_ERRORS_PER_PAGE) {
return;
}
if (Debugger._error_batch_count >= Debugger.MAX_ERROR_BATCHES) {
return;
}
Debugger._error_count++;
// Add metadata
errorData.url = window.location.href;
errorData.userAgent = navigator.userAgent;
errorData.timestamp = new Date().toISOString();
// Add to batch
Debugger._error_batch.push(errorData);
// Clear existing timer
if (Debugger._error_timer) {
clearTimeout(Debugger._error_timer);
}
// Set debounce timer
Debugger._error_timer = setTimeout(() => {
Debugger._flush_error_batch();
}, Debugger.DEBOUNCE_MS);
}
/**
* Internal: Batch console_debug messages for Laravel log
*/
static _batch_console_message(message) {
Debugger._console_batch.push(message);
// Clear existing timer
if (Debugger._console_timer) {
clearTimeout(Debugger._console_timer);
}
// Set debounce timer
Debugger._console_timer = setTimeout(() => {
Debugger._flush_console_batch();
}, Debugger.DEBOUNCE_MS);
}
/**
* Internal: Flush console_debug batch to server
*/
static async _flush_console_batch() {
if (Debugger._console_batch.length === 0) {
return;
}
const messages = Debugger._console_batch;
Debugger._console_batch = [];
Debugger._console_timer = null;
try {
await $.ajax({
url: '/_ajax/Debugger_Controller/log_console_messages',
method: 'POST',
data: JSON.stringify({ messages: messages }),
contentType: 'application/json',
dataType: 'json',
});
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send console_debug messages to server:', error);
}
}
/**
* Internal: Flush error batch to server
*/
static async _flush_error_batch() {
if (Debugger._error_batch.length === 0) {
return;
}
const errors = Debugger._error_batch;
Debugger._error_batch = [];
Debugger._error_timer = null;
Debugger._error_batch_count++;
try {
await $.ajax({
url: '/_ajax/Debugger_Controller/log_browser_errors',
method: 'POST',
data: JSON.stringify({ errors: errors }),
contentType: 'application/json',
dataType: 'json',
});
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send browser errors to server:', error);
}
}
/**
* Internal: Get time prefix for benchmarking
*/
static _get_time_prefix() {
const now = Date.now();
if (!Debugger._start_time) {
Debugger._start_time = now;
}
const elapsed = now - Debugger._start_time;
return (elapsed / 1000).toFixed(3) + 's';
}
}

339
app/RSpade/Core/Js/Form.js Executable file
View File

@@ -0,0 +1,339 @@
/**
* Form utilities for validation and error handling
*/
class Form {
/**
* 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.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.reset_form_errors(parent_selector);
// Normalize input to standard format
const normalized = Form._normalize_errors(errors);
return new Promise((resolve) => {
let animations = [];
if (normalized.type === 'string') {
// Single error message
animations = Form._apply_general_errors($parent, normalized.data);
} else if (normalized.type === 'array') {
// Array of error messages
const deduplicated = Form._deduplicate_errors(normalized.data);
animations = Form._apply_general_errors($parent, deduplicated);
} else if (normalized.type === 'fields') {
// Field-specific errors
const result = Form._apply_field_errors($parent, normalized.data);
animations = result.animations;
// Show unmatched errors as general alert
const unmatched_deduplicated = Form._deduplicate_errors(result.unmatched);
if (Object.keys(unmatched_deduplicated).length > 0) {
const unmatched_animations = Form._apply_general_errors($parent, unmatched_deduplicated);
animations.push(...unmatched_animations);
}
}
// Resolve the promise once all animations are complete
Promise.all(animations).then(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.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.serialize($form);
Form.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.apply_form_errors(form_selector, error.details);
} else {
await Form.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._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._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 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 = [];
if (typeof messages === 'string') {
// Single error - simple alert without list
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
animations.push($alert.hide().prependTo($parent).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);
});
animations.push($alert.hide().prependTo($parent).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._apply_general_errors($parent, error_list);
}
}
return animations;
}
}

361
app/RSpade/Core/Js/Manifest.js Executable file
View File

@@ -0,0 +1,361 @@
/**
* Manifest - JavaScript class registry and metadata system
*
* This class maintains a registry of all JavaScript classes in the bundle,
* tracking their names and inheritance relationships. It provides utilities
* for working with class hierarchies and calling initialization methods.
*/
class Manifest {
/**
* Define classes in the manifest (framework internal)
* @param {Array} items - Array of class definitions [[Class, "ClassName", ParentClass, decorators], ...]
*/
static _define(items) {
// Initialize the classes object if not already defined
if (typeof Manifest._classes === 'undefined') {
Manifest._classes = {};
}
// Process each class definition
items.forEach((item) => {
let class_object = item[0];
let class_name = item[1];
let class_extends = item[2] || null;
let decorators = item[3] || null;
// Store the class information (using object to avoid duplicates)
Manifest._classes[class_name] = {
class: class_object,
name: class_name,
extends: class_extends,
decorators: decorators, // Store compact decorator data
};
// Add metadata to the class object itself
class_object._name = class_name;
class_object._extends = class_extends;
class_object._decorators = decorators;
});
// Build the subclass index after all classes are defined
Manifest._build_subclass_index();
}
/**
* Build an index of subclasses for efficient lookups
* This creates a mapping where each class name points to an array of all its subclasses
* @private
*/
static _build_subclass_index() {
// Initialize the subclass index
Manifest._subclass_index = {};
// Step through each class and walk up its parent chain
for (let class_name in Manifest._classes) {
const classdata = Manifest._classes[class_name];
let current_class_name = class_name;
let current_classdata = classdata;
// Walk up the parent chain until we reach the root
while (current_classdata) {
const extends_name = current_classdata.extends;
if (extends_name) {
// Initialize the parent's subclass array if needed
if (!Manifest._subclass_index[extends_name]) {
Manifest._subclass_index[extends_name] = [];
}
// Add this class to its parent's subclass list
if (!Manifest._subclass_index[extends_name].includes(class_name)) {
Manifest._subclass_index[extends_name].push(class_name);
}
// Move up to the parent's metadata (if it exists in manifest)
if (Manifest._classes[extends_name]) {
current_classdata = Manifest._classes[extends_name];
} else {
// Parent not in manifest (e.g., native JavaScript class), stop here
current_classdata = null;
}
} else {
// No parent, we've reached the root
current_classdata = null;
}
}
}
}
/**
* Get all classes that extend a given base class
* @param {Class|string} base_class - The base class (object or name string) to check for
* @returns {Array} Array of objects with {class_name, class_object} for classes that extend the base class
*/
static get_extending(base_class) {
if (!Manifest._classes) {
return [];
}
// Convert string to class object if needed
let base_class_object = base_class;
if (typeof base_class === 'string') {
base_class_object = Manifest.get_class_by_name(base_class);
if (!base_class_object) {
throw new Error(`Base class not found: ${base_class}`);
}
}
const classes = [];
for (let class_name in Manifest._classes) {
const classdata = Manifest._classes[class_name];
if (Manifest.js_is_subclass_of(classdata.class, base_class_object)) {
classes.push({
class_name: class_name,
class_object: classdata.class,
});
}
}
// Sort alphabetically by class name to ensure deterministic behavior and prevent race condition bugs
classes.sort((a, b) => a.class_name.localeCompare(b.class_name));
return classes;
}
/**
* Check if a class is a subclass of another class
* Matches PHP Manifest::js_is_subclass_of() signature and behavior
* @param {Class|string} subclass - The child class (object or name) to check
* @param {Class|string} superclass - The parent class (object or name) to check against
* @returns {boolean} True if subclass extends superclass (directly or indirectly)
*/
static js_is_subclass_of(subclass, superclass) {
// Convert string names to class objects
let subclass_object = subclass;
if (typeof subclass === 'string') {
subclass_object = Manifest.get_class_by_name(subclass);
if (!subclass_object) {
// Can't resolve subclass - return false per spec
return false;
}
}
let superclass_object = superclass;
if (typeof superclass === 'string') {
superclass_object = Manifest.get_class_by_name(superclass);
if (!superclass_object) {
// Can't resolve superclass - fail loud per spec
throw new Error(`Superclass not found in manifest: ${superclass}`);
}
}
// Classes are not subclasses of themselves
if (subclass_object === superclass_object) {
return false;
}
// Walk up the inheritance chain
let current_class = subclass_object;
while (current_class) {
if (current_class === superclass_object) {
return true;
}
// Move up to parent class
if (current_class._extends) {
// _extends may be a string or class reference
if (typeof current_class._extends === 'string') {
current_class = Manifest.get_class_by_name(current_class._extends);
} else {
current_class = current_class._extends;
}
} else {
current_class = null;
}
}
return false;
}
/**
* Get a class by its name
* @param {string} class_name - The name of the class
* @returns {Class|null} The class object or null if not found
*/
static get_class_by_name(class_name) {
if (!Manifest._classes || !Manifest._classes[class_name]) {
return null;
}
return Manifest._classes[class_name].class;
}
/**
* Get all registered classes
* @returns {Array} Array of objects with {class_name, class_object, extends}
*/
static get_all_classes() {
if (!Manifest._classes) {
return [];
}
const results = [];
for (let class_name in Manifest._classes) {
const classdata = Manifest._classes[class_name];
results.push({
class_name: classdata.name,
class_object: classdata.class,
extends: classdata.extends,
});
}
// Sort alphabetically by class name to ensure deterministic behavior and prevent race condition bugs
results.sort((a, b) => a.class_name.localeCompare(b.class_name));
return results;
}
/**
* Get the build key from the application configuration
* @returns {string} The build key or "NOBUILD" if not available
*/
static build_key() {
if (window.rsxapp && window.rsxapp.build_key) {
return window.rsxapp.build_key;
}
return 'NOBUILD';
}
/**
* Get decorators for a specific class and method
* @param {string|Class} class_name - The class name or class object
* @param {string} method_name - The method name
* @returns {Array|null} Array of decorator objects or null if none found
*/
static get_decorators(class_name, method_name) {
// Convert class object to name if needed
if (typeof class_name !== 'string') {
class_name = class_name._name || class_name.name;
}
const class_info = Manifest._classes[class_name];
if (!class_info || !class_info.decorators || !class_info.decorators[method_name]) {
return null;
}
// Transform compact format to object format
return Manifest._transform_decorators(class_info.decorators[method_name]);
}
/**
* Get all methods with decorators for a class
* @param {string|Class} class_name - The class name or class object
* @returns {Object} Object with method names as keys and decorator arrays as values
*/
static get_all_decorators(class_name) {
// Convert class object to name if needed
if (typeof class_name !== 'string') {
class_name = class_name._name || class_name.name;
}
const class_info = Manifest._classes[class_name];
if (!class_info || !class_info.decorators) {
return {};
}
// Transform all decorators from compact to object format
const result = {};
for (let method_name in class_info.decorators) {
result[method_name] = Manifest._transform_decorators(class_info.decorators[method_name]);
}
return result;
}
/**
* Transform compact decorator format to object format
* @param {Array} compact_decorators - Array of [name, [args]] tuples
* @returns {Array} Array of decorator objects with name and arguments properties
* @private
*/
static _transform_decorators(compact_decorators) {
if (!Array.isArray(compact_decorators)) {
return [];
}
return compact_decorators.map(decorator => {
if (Array.isArray(decorator) && decorator.length >= 2) {
return {
name: decorator[0],
arguments: decorator[1] || []
};
}
// Handle malformed decorator data
return {
name: 'unknown',
arguments: []
};
});
}
/**
* Check if a method has a specific decorator
* @param {string|Class} class_name - The class name or class object
* @param {string} method_name - The method name
* @param {string} decorator_name - The decorator name to check for
* @returns {boolean} True if the method has the decorator
*/
static has_decorator(class_name, method_name, decorator_name) {
const decorators = Manifest.get_decorators(class_name, method_name);
if (!decorators) {
return false;
}
return decorators.some(d => d.name === decorator_name);
}
/**
* Get all subclasses of a given class using the pre-built index
* This is the JavaScript equivalent of PHP's Manifest::js_get_subclasses_of()
* @param {Class|string} base_class - The base class (object or name string) to get subclasses of
* @returns {Array<Class>} Array of actual class objects that are subclasses of the base class
*/
static js_get_subclasses_of(base_class) {
// Initialize index if needed
if (!Manifest._subclass_index) {
Manifest._build_subclass_index();
}
// Convert class object to name if needed
let base_class_name = base_class;
if (typeof base_class !== 'string') {
base_class_name = base_class._name || base_class.name;
}
// Check if the base class exists
if (!Manifest._classes[base_class_name]) {
// Base class not in manifest - return empty array
return [];
}
// Get subclass names from the index
const subclass_names = Manifest._subclass_index[base_class_name] || [];
// Convert names to actual class objects
const subclass_objects = [];
for (let subclass_name of subclass_names) {
const classdata = Manifest._classes[subclass_name];
subclass_objects.push(classdata.class);
}
// Sort by class name for deterministic behavior
subclass_objects.sort((a, b) => {
const name_a = a._name || a.name;
const name_b = b._name || b.name;
return name_a.localeCompare(name_b);
});
return subclass_objects;
}
}
// RSX manifest automatically makes classes global - no manual assignment needed

127
app/RSpade/Core/Js/Mutex.js Executable file
View File

@@ -0,0 +1,127 @@
/**
* Mutex decorator for exclusive method execution
*
* Without arguments: Per-instance locking (each object has its own lock per method)
* @mutex
* async my_method() { ... }
*
* With ID argument: Global locking by ID (all instances share the lock)
* @mutex('operation_name')
* async my_method() { ... }
*
* @decorator
* @param {string} [global_id] - Optional global mutex ID for cross-instance locking
*/
function mutex(global_id) {
// Storage (using IIFEs to keep WeakMap/Map in closure scope)
const instance_mutexes = (function() {
if (!mutex._instance_storage) {
mutex._instance_storage = new WeakMap();
}
return mutex._instance_storage;
})();
const global_mutexes = (function() {
if (!mutex._global_storage) {
mutex._global_storage = new Map();
}
return mutex._global_storage;
})();
/**
* Get or create a mutex for a specific instance and method
*/
function get_instance_mutex(instance, method_name) {
let instance_locks = instance_mutexes.get(instance);
if (!instance_locks) {
instance_locks = new Map();
instance_mutexes.set(instance, instance_locks);
}
let lock_state = instance_locks.get(method_name);
if (!lock_state) {
lock_state = { active: false, queue: [] };
instance_locks.set(method_name, lock_state);
}
return lock_state;
}
/**
* Get or create a global mutex by ID
*/
function get_global_mutex(id) {
let lock_state = global_mutexes.get(id);
if (!lock_state) {
lock_state = { active: false, queue: [] };
global_mutexes.set(id, lock_state);
}
return lock_state;
}
/**
* Execute the next queued operation for a mutex
*/
function schedule_next(lock_state) {
if (lock_state.active || lock_state.queue.length === 0) {
return;
}
const { fn, resolve, reject } = lock_state.queue.shift();
lock_state.active = true;
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => {
lock_state.active = false;
schedule_next(lock_state);
});
}
/**
* Acquire a mutex lock and execute callback
*/
function acquire_lock(lock_state, fn) {
return new Promise((resolve, reject) => {
lock_state.queue.push({ fn, resolve, reject });
schedule_next(lock_state);
});
}
// If called with an ID argument: @mutex('id')
if (typeof global_id === 'string') {
return function(target, key, descriptor) {
const original_method = descriptor.value;
if (typeof original_method !== 'function') {
throw new Error(`@mutex can only be applied to methods (tried to apply to ${key})`);
}
descriptor.value = function(...args) {
const lock_state = get_global_mutex(global_id);
return acquire_lock(lock_state, () => original_method.apply(this, args));
};
return descriptor;
};
}
// If called without arguments: @mutex (target is the first argument)
const target = global_id; // In this case, first arg is target
const key = arguments[1];
const descriptor = arguments[2];
const original_method = descriptor.value;
if (typeof original_method !== 'function') {
throw new Error(`@mutex can only be applied to methods (tried to apply to ${key})`);
}
descriptor.value = function(...args) {
const lock_state = get_instance_mutex(this, key);
return acquire_lock(lock_state, () => original_method.apply(this, args));
};
return descriptor;
}

View File

@@ -0,0 +1,123 @@
/**
* ReadWriteLock implementation for RSpade framework
* Provides exclusive (write) and shared (read) locking mechanisms for asynchronous operations
*/
class ReadWriteLock {
static #locks = new Map();
/**
* Get or create a lock object for a given name
* @private
*/
static #get_lock(name) {
let s = this.#locks.get(name);
if (!s) {
s = { readers: 0, writer_active: false, reader_q: [], writer_q: [] };
this.#locks.set(name, s);
}
return s;
}
/**
* Schedule the next operation for a lock
* @private
*/
static #schedule(name) {
const s = this.#get_lock(name);
if (s.writer_active || s.readers > 0) return;
// run one writer if queued
if (s.writer_q.length > 0) {
const { cb, resolve, reject } = s.writer_q.shift();
s.writer_active = true;
Promise.resolve()
.then(cb)
.then(resolve, reject)
.finally(() => {
s.writer_active = false;
this.#schedule(name);
});
return;
}
// otherwise run all queued readers in parallel
if (s.reader_q.length > 0) {
const batch = s.reader_q.splice(0);
s.readers += batch.length;
for (const { cb, resolve, reject } of batch) {
Promise.resolve()
.then(cb)
.then(resolve, reject)
.finally(() => {
s.readers -= 1;
if (s.readers === 0) this.#schedule(name);
});
}
}
}
/**
* Acquire an exclusive mutex lock by name.
* Only one writer runs at a time; blocks readers until finished.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
static acquire(name, cb) {
return new Promise((resolve, reject) => {
const s = this.#get_lock(name);
s.writer_q.push({ cb, resolve, reject });
this.#schedule(name);
});
}
/**
* Acquire a shared read lock by name.
* Multiple readers can run in parallel; blocks when writer is active.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
static acquire_read(name, cb) {
return new Promise((resolve, reject) => {
const s = this.#get_lock(name);
if (s.writer_active || s.writer_q.length > 0) {
s.reader_q.push({ cb, resolve, reject });
return this.#schedule(name);
}
s.readers += 1;
Promise.resolve()
.then(cb)
.then(resolve, reject)
.finally(() => {
s.readers -= 1;
if (s.readers === 0) this.#schedule(name);
});
});
}
/**
* Force-unlock a mutex (use with caution).
* Completely removes the lock state, potentially breaking waiting operations.
* @param {string} name
*/
static force_unlock(name) {
this.#locks.delete(name);
}
/**
* Get information about pending operations on a mutex.
* @param {string} name
* @returns {{readers: number, writer_active: boolean, reader_q: number, writer_q: number}}
*/
static pending(name) {
const s = this.#locks.get(name);
if (!s) return { readers: 0, writer_active: false, reader_q: 0, writer_q: 0 };
return {
readers: s.readers,
writer_active: s.writer_active,
reader_q: s.reader_q.length,
writer_q: s.writer_q.length
};
}
}

326
app/RSpade/Core/Js/Rsx.js Executable file
View File

@@ -0,0 +1,326 @@
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
/**
* Rsx - Core JavaScript Runtime System
*
* The Rsx class is the central hub for the RSX JavaScript runtime, providing essential
* system-level utilities that all other framework components depend on. It serves as the
* foundation for the client-side framework, handling core operations that must be globally
* accessible and consistently available.
*
* Core Responsibilities:
* - Event System: Application-wide event bus for framework lifecycle and custom events
* - Environment Detection: Runtime environment identification (dev/production)
* - Route Management: Type-safe route generation and URL building
* - Unique ID Generation: Client-side unique identifier generation
* - Framework Bootstrap: Multi-phase initialization orchestration
* - Logging: Centralized logging interface (delegates to console_debug)
*
* The Rsx class deliberately keeps its scope limited to core utilities. Advanced features
* are delegated to specialized classes:
* - Manifest operations → Manifest class
* - Caching → Rsx_Cache class
* - AJAX/API calls → Ajax_* classes
* - Route proxies → Rsx_Route_Proxy class
* - Behaviors → Rsx_Behaviors class
*
* All methods are static - Rsx is never instantiated. It's available globally from the
* moment bundles load and remains constant throughout the application lifecycle.
*
* Usage Examples:
* ```javascript
* // Event system
* Rsx.on('app_ready', () => console.log('App initialized'));
* Rsx.trigger('custom_event', {data: 'value'});
*
* // Environment detection
* if (Rsx.is_dev()) { console.log('Development mode'); }
*
* // Route generation
* const url = Rsx.Route('Controller', 'action').url();
*
* // Unique IDs
* const uniqueId = Rsx.uid(); // e.g., "rsx_1234567890_1"
* ```
*
* @static
* @global
*/
class Rsx {
// Gets set to true to interupt startup sequence
static __stopped = false;
// Initialize event handlers storage
static _init_events() {
if (typeof Rsx._event_handlers === 'undefined') {
Rsx._event_handlers = {};
}
if (typeof Rsx._triggered_events === 'undefined') {
Rsx._triggered_events = {};
}
}
// Register an event handler
static on(event, callback) {
Rsx._init_events();
if (typeof callback !== 'function') {
throw new Error('Callback must be a function');
}
if (!Rsx._event_handlers[event]) {
Rsx._event_handlers[event] = [];
}
Rsx._event_handlers[event].push(callback);
// If this event was already triggered, call the callback immediately
if (Rsx._triggered_events[event]) {
console_debug('RSX_INIT', 'Triggering ' + event + ' for late registered callback');
callback(Rsx._triggered_events[event]);
}
}
// Trigger an event with optional data
static trigger(event, data = {}) {
Rsx._init_events();
// Record that this event was triggered
Rsx._triggered_events[event] = data;
if (!Rsx._event_handlers[event]) {
return;
}
console_debug('RSX_INIT', 'Triggering ' + event + ' for ' + Rsx._event_handlers[event].length + ' callbacks');
// Call all registered handlers for this event in order
for (const callback of Rsx._event_handlers[event]) {
callback(data);
}
}
// Alias for trigger.refresh(''), should be called after major UI updates to apply such effects as
// underlining links to unimplemented # routes
static trigger_refresh() {
// Use Rsx.on('refresh', callback); to register a callback for refresh
this.trigger('refresh');
}
// Log to server that an event happened
static log(type, message = 'notice') {
Core_Log.log(type, message);
}
// Returns true if the app is being run in dev mode
// This should affect caching and some debug checks
static is_dev() {
return window.rsxapp.debug;
}
static is_prod() {
return !window.rsxapp.debug;
}
// Generates a unique number for the application instance
static uid() {
if (typeof Rsx._uid == undef) {
Rsx._uid = 0;
}
return Rsx._uid++;
}
// Storage for route definitions loaded from bundles
static _routes = {};
/**
* 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];
}
}
}
/**
* Create a route proxy for type-safe URL generation
*
* This method creates a route proxy that can generate URLs for a specific controller action.
* The proxy ensures all required route parameters are provided and handles extra parameters
* as query string values.
*
* Usage examples:
* ```javascript
* // Simple route without parameters (defaults to 'index' action)
* const url = Rsx.Route('Frontend_Index_Controller').url();
* // Returns: /dashboard
*
* // Route with explicit action
* const url = Rsx.Route('Frontend_Index_Controller', 'index').url();
* // Returns: /dashboard
*
* // Route with required parameter
* const url = Rsx.Route('Frontend_Client_View_Controller').url({id: 'C001'});
* // Returns: /clients/view/C001
*
* // Route with required and query parameters
* const url = Rsx.Route('Frontend_Client_View_Controller').url({
* id: 'C001',
* tab: 'history'
* });
* // Returns: /clients/view/C001?tab=history
*
* // Generate absolute URL
* const absolute = Rsx.Route('Frontend_Index_Controller').absolute_url();
* // Returns: https://example.com/dashboard
*
* // Navigate to route
* Rsx.Route('Frontend_Index_Controller').navigate();
* // Redirects browser to /dashboard
*
* // Check if route is current
* if (Rsx.Route('Frontend_Index_Controller').is_current()) {
* // This is the currently executing route
* }
* ```
*
* @param {string} class_name The controller class name (e.g., 'User_Controller')
* @param {string} [action_name='index'] The action/method name (defaults to 'index')
* @returns {Rsx_Route_Proxy} Route proxy instance for URL generation
* @throws {Error} If route not found
*/
static Route(class_name, action_name = 'index') {
// Check if route exists
if (!Rsx._routes[class_name]) {
throw new Error(`Class ${class_name} not found in routes`);
}
if (!Rsx._routes[class_name][action_name]) {
throw new Error(`Method ${action_name} not found in class ${class_name}`);
}
const pattern = Rsx._routes[class_name][action_name];
return new Rsx_Route_Proxy(class_name, action_name, pattern);
}
/**
* Internal: Call a specific method on all classes that have it
* Collects promises from return values and waits for all to resolve
* @param {string} method_name The method name to call on all classes
* @returns {Promise} Promise that resolves when all method calls complete
*/
static async _rsx_call_all_classes(method_name) {
const all_classes = Manifest.get_all_classes();
const classes_with_method = [];
const promise_pile = [];
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 has the method (static methods are on the class itself)
if (typeof class_object[method_name] === 'function') {
classes_with_method.push(class_name);
const return_value = await class_object[method_name]();
// Collect promises from return value
if (return_value instanceof Promise) {
promise_pile.push(return_value);
} else if (Array.isArray(return_value)) {
for (const item of return_value) {
if (item instanceof Promise) {
promise_pile.push(item);
}
}
}
if (Rsx.__stopped) {
return;
}
}
}
if (classes_with_method.length > 0) {
console_debug('RSX_INIT', `${method_name}: ${classes_with_method.length} classes`);
}
// Await all promises before returning
if (promise_pile.length > 0) {
console_debug('RSX_INIT', `${method_name}: Awaiting ${promise_pile.length} promises`);
await Promise.all(promise_pile);
}
}
/**
* Internal: Execute multi-phase initialization for all registered classes
* This runs various initialization phases in order to properly set up the application
* @returns {Promise} Promise that resolves when all initialization phases complete
*/
static async _rsx_core_boot() {
if (Rsx.__booted) {
console.error('Rsx._rsx_core_boot called more than once');
return;
}
Rsx.__booted = true;
// Get all registered classes from the manifest
const all_classes = Manifest.get_all_classes();
console_debug('RSX_INIT', `Starting _rsx_core_boot with ${all_classes.length} classes`);
if (!all_classes || all_classes.length === 0) {
// No classes to initialize
shouldnt_happen('No classes registered in js - there should be at least the core framework classes');
return;
}
// Define initialization phases in order
const phases = [
{ event: 'framework_core_define', method: '_on_framework_core_define' },
{ event: 'framework_modules_define', method: '_on_framework_modules_define' },
{ event: 'framework_core_init', method: '_on_framework_core_init' },
{ event: 'app_modules_define', method: 'on_app_modules_define' },
{ event: 'app_define', method: 'on_app_define' },
{ event: 'framework_modules_init', method: '_on_framework_modules_init' },
{ event: 'app_modules_init', method: 'on_app_modules_init' },
{ event: 'app_init', method: 'on_app_init' },
{ event: 'app_ready', method: 'on_app_ready' },
];
// Execute each phase in order
for (const phase of phases) {
await Rsx._rsx_call_all_classes(phase.method);
if (Rsx.__stopped) {
return;
}
Rsx.trigger(phase.event);
}
// Ui refresh callbacks
Rsx.trigger_refresh();
// All phases complete
console_debug('RSX_INIT', 'Initialization complete');
// Trigger _debug_ready event - this is ONLY for tooling like rsx:debug
// DO NOT use this in application code - use on_app_ready() phase instead
// This event exists solely for debugging tools that need to run after full initialization
Rsx.trigger('_debug_ready');
}
/* Calling this stops the boot process. */
static async _rsx_core_boot_stop(reason) {
console.error(reason);
Rsx.__stopped = true;
}
}

View File

@@ -0,0 +1,348 @@
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Rsx_Ajax_Batch - Batches multiple Ajax calls into single HTTP request
*
* Inspired by RS3's batch API system. All Ajax.call() requests are batched
* together into a single POST to /_ajax/_batch. This dramatically reduces
* HTTP requests from N to 1-3 per page.
*
* Key behaviors:
* - Batches up to 20 calls, then flushes immediately (MAX_BATCH_SIZE)
* - New calls after flush go into next batch
* - Uses setTimeout(0) debounce when under batch size limit (DEBOUNCE_MS)
* - Deduplicates identical calls (same controller/action/params)
* - Returns cached results for duplicate calls
* - Can be disabled via window.rsxapp.ajax_disable_batching for debugging
*/
class Rsx_Ajax_Batch {
/**
* Initialize batch system
* Called automatically when class is loaded
*/
static init() {
// Queue of pending calls waiting to be batched
Rsx_Ajax_Batch._pending_calls = {};
// Timer for batching flush
Rsx_Ajax_Batch._flush_timeout = null;
// Call counter for generating unique call IDs
Rsx_Ajax_Batch._call_counter = 0;
// Maximum batch size before forcing immediate flush
Rsx_Ajax_Batch.MAX_BATCH_SIZE = 20;
// Debounce time in milliseconds
Rsx_Ajax_Batch.DEBOUNCE_MS = 0;
}
/**
* Queue an Ajax call for batching
*
* @param {string} controller - Controller class name
* @param {string} action - Action method name
* @param {object} params - Parameters to send
* @returns {Promise} - Resolves with return value or rejects with error
*/
static call(controller, action, params = {}) {
// Check if batching is disabled for debugging
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
// Make individual request immediately
return Rsx_Ajax_Batch._make_individual_request(controller, action, params);
}
return new Promise((resolve, reject) => {
// Generate call key for deduplication
// Same controller + action + params = same call
const call_key = Rsx_Ajax_Batch._generate_call_key(controller, action, params);
// Check if this exact call is already pending
if (Rsx_Ajax_Batch._pending_calls[call_key]) {
const existing_call = Rsx_Ajax_Batch._pending_calls[call_key];
// If call already completed (cached), return immediately
if (existing_call.is_complete) {
if (existing_call.is_error) {
reject(existing_call.error);
} else {
resolve(existing_call.result);
}
return;
}
// Call is pending, add this promise to callbacks
existing_call.callbacks.push({ resolve, reject });
return;
}
// Create new pending call
const call_id = Rsx_Ajax_Batch._call_counter++;
const pending_call = {
call_id: call_id,
call_key: call_key,
controller: controller,
action: action,
params: params,
callbacks: [{ resolve, reject }],
is_complete: false,
is_error: false,
result: null,
error: null,
};
// Add to pending queue
Rsx_Ajax_Batch._pending_calls[call_key] = pending_call;
// Count pending calls
const pending_count = Object.keys(Rsx_Ajax_Batch._pending_calls).filter(
(key) => !Rsx_Ajax_Batch._pending_calls[key].is_complete
).length;
// If we've hit the batch size limit, flush immediately
if (pending_count >= Rsx_Ajax_Batch.MAX_BATCH_SIZE) {
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
Rsx_Ajax_Batch._flush_timeout = null;
Rsx_Ajax_Batch._flush_pending_calls();
} else {
// Schedule batch flush with debounce
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
Rsx_Ajax_Batch._flush_timeout = setTimeout(() => {
Rsx_Ajax_Batch._flush_pending_calls();
}, Rsx_Ajax_Batch.DEBOUNCE_MS);
}
});
}
/**
* Flush all pending calls by sending batch request
* @private
*/
static async _flush_pending_calls() {
// Collect all pending calls
const calls_to_send = [];
const call_map = {}; // Map call_id to pending_call object
for (const call_key in Rsx_Ajax_Batch._pending_calls) {
const pending_call = Rsx_Ajax_Batch._pending_calls[call_key];
if (!pending_call.is_complete) {
calls_to_send.push({
call_id: pending_call.call_id,
controller: pending_call.controller,
action: pending_call.action,
params: pending_call.params,
});
call_map[pending_call.call_id] = pending_call;
}
}
// Nothing to send
if (calls_to_send.length === 0) {
return;
}
// Log batch for debugging
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug(
'AJAX_BATCH',
`Sending batch of ${calls_to_send.length} calls`,
calls_to_send.map((c) => `${c.controller}.${c.action}`)
);
}
try {
// Send batch request
const response = await $.ajax({
url: '/_ajax/_batch',
method: 'POST',
data: { batch_calls: JSON.stringify(calls_to_send) },
dataType: 'json',
__local_integration: true, // Bypass $.ajax override
});
// Process batch response
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... }
for (const response_key in response) {
if (!response_key.startsWith('C_')) {
continue;
}
const call_id = parseInt(response_key.substring(2), 10);
const call_response = response[response_key];
const pending_call = call_map[call_id];
if (!pending_call) {
console.error('Received response for unknown call_id:', call_id);
continue;
}
// Handle console_debug messages if present
if (call_response.console_debug && Array.isArray(call_response.console_debug)) {
call_response.console_debug.forEach((msg) => {
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
// Mark call as complete
pending_call.is_complete = true;
// Check if successful
if (call_response.success === true) {
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
// Process the return value to instantiate any ORM models
const processed_value = Rsx_Js_Model._instantiate_models_recursive(call_response._ajax_return_value);
pending_call.result = processed_value;
// Resolve all callbacks
pending_call.callbacks.forEach(({ resolve }) => {
resolve(processed_value);
});
} else {
// Handle error
const error_type = call_response.error_type || 'unknown_error';
const reason = call_response.reason || 'Unknown error occurred';
const details = call_response.details || {};
const error = new Error(reason);
error.type = error_type;
error.details = details;
pending_call.is_error = true;
pending_call.error = error;
// Reject all callbacks
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
}
} catch (xhr_error) {
// Network or server error - reject all pending calls
const error_message = Rsx_Ajax_Batch._extract_error_message(xhr_error);
const error = new Error(error_message);
error.type = 'network_error';
for (const call_id in call_map) {
const pending_call = call_map[call_id];
pending_call.is_complete = true;
pending_call.is_error = true;
pending_call.error = error;
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
console.error('Batch Ajax request failed:', error_message);
}
}
/**
* Make an individual Ajax request (when batching is disabled)
* @private
*/
static async _make_individual_request(controller, action, params) {
const url = `/_ajax/${controller}/${action}`;
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug('AJAX', `Calling ${controller}.${action} (unbatched)`, params);
}
return new Promise((resolve, reject) => {
$.ajax({
url: url,
method: 'POST',
data: params,
dataType: 'json',
__local_integration: true,
success: (response) => {
// Handle console_debug messages
if (response.console_debug && Array.isArray(response.console_debug)) {
response.console_debug.forEach((msg) => {
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
if (response.success === true) {
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
const processed_value = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
resolve(processed_value);
} else {
const error_type = response.error_type || 'unknown_error';
const reason = response.reason || 'Unknown error occurred';
const details = response.details || {};
const error = new Error(reason);
error.type = error_type;
error.details = details;
reject(error);
}
},
error: (xhr, status, error) => {
const error_message = Rsx_Ajax_Batch._extract_error_message(xhr);
const network_error = new Error(error_message);
network_error.type = 'network_error';
network_error.status = xhr.status;
network_error.statusText = status;
reject(network_error);
},
});
});
}
/**
* Generate a unique key for deduplicating calls
* @private
*/
static _generate_call_key(controller, action, params) {
// Create a stable string representation of the call
// Sort params keys for consistent hashing
const sorted_params = {};
Object.keys(params)
.sort()
.forEach((key) => {
sorted_params[key] = params[key];
});
return `${controller}::${action}::${JSON.stringify(sorted_params)}`;
}
/**
* Extract error message from jQuery XHR object
* @private
*/
static _extract_error_message(xhr) {
if (xhr.responseJSON && xhr.responseJSON.message) {
return xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
return response.message;
}
} catch (e) {
// Not JSON
}
}
return `${xhr.status}: ${xhr.statusText || 'Unknown error'}`;
}
/**
* Auto-initialize static properties when class is first loaded
* Called by on_core_define lifecycle hook
*/
static on_core_define() {
Rsx_Ajax_Batch.init();
}
}

View File

@@ -0,0 +1,108 @@
/**
* Rsx_Behaviors - Core Framework User Experience Enhancements
*
* This class provides automatic quality-of-life behaviors that improve the default
* browser experience for RSX applications. These behaviors are transparent to
* application developers and run automatically on framework initialization.
*
* These behaviors use jQuery event delegation to handle both existing and dynamically
* added content. They are implemented with low priority to allow application code to
* override default behaviors when needed.
*
* @internal Framework use only - not part of public API
*/
class Rsx_Behaviors {
static _on_framework_core_init() {
Rsx_Behaviors._init_ignore_invalid_anchor_links();
Rsx_Behaviors._trim_copied_text();
}
/**
* - Anchor link handling: Prevents broken "#" links from causing page jumps or URL changes
* - Ignores "#" (empty hash) to prevent scroll-to-top behavior
* - Ignores "#placeholder*" links used as route placeholders during development
* - Validates anchor targets exist before allowing navigation
* - Preserves normal anchor behavior when targets exist
*/
static _init_ignore_invalid_anchor_links() {
return; // disabled for now - make this into a configurable option
// Use event delegation on document to handle all current and future anchor clicks
// Use mousedown instead of click to run before most application handlers
$(document).on('mousedown', 'a[href^="#"]', function (e) {
const $link = $(this);
const href = $link.attr('href');
// Check if another handler has already prevented default
if (e.isDefaultPrevented()) {
return;
}
// Allow data-rsx-allow-hash attribute to bypass this behavior
if ($link.data('rsx-allow-hash')) {
return;
}
// Handle empty hash - prevent scroll to top
if (href === '#') {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// Handle placeholder links used during development
if (href.startsWith('#placeholder')) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// For other hash links, check if target exists
const targetId = href.substring(1);
if (targetId) {
// Check for element with matching ID or name attribute
const targetExists = document.getElementById(targetId) !== null || document.querySelector(`[name="${targetId}"]`) !== null;
if (!targetExists) {
// Target doesn't exist - prevent navigation
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// Target exists - allow normal anchor behavior
}
});
}
/**
* - Copy text trimming: Automatically removes leading/trailing whitespace from copied text
* - Hold Shift to preserve whitespace
* - Skips trimming in code blocks, textareas, and contenteditable elements
*/
static _trim_copied_text() {
document.addEventListener('copy', function (event) {
// Don't trim if user is holding Shift (allows copying with whitespace if needed)
if (event.shiftKey) return;
let selection = window.getSelection();
let selected_text = selection.toString();
// Don't trim if selection is empty
if (!selected_text) return;
// Don't trim if copying from code blocks, textareas, or content-editable (preserve formatting)
let container = selection.getRangeAt(0).commonAncestorContainer;
if (container.nodeType === 3) container = container.parentNode; // Text node to element
if (container.closest('pre, code, .code-block, textarea, [contenteditable="true"]')) return;
let trimmed_text = selected_text.trim();
// Only modify if there's actually whitespace to trim
if (trimmed_text !== selected_text && trimmed_text.length > 0) {
event.preventDefault();
event.clipboardData.setData('text/plain', trimmed_text);
console.log('Copy: trimmed whitespace from selection');
}
});
}
}

210
app/RSpade/Core/Js/Rsx_Cache.js Executable file
View File

@@ -0,0 +1,210 @@
// Simple key value cache. Can only store 5000 entries, will reset after 5000 entries.
// Todo: keep local cache concept the same, replace global cache concept with the nov 2019 version of
// session cache. Use a session key & build key to track cache keys so cached values only last until user logs out.
// review session code to ensure that session key *always* rotates on logout. Make session id a protected value.
class Rsx_Cache {
static on_core_define() {
Core_Cache._caches = {
global: {},
instance: {},
};
Core_Cache._caches_set = 0;
}
// Alias for get_instance
static get(key) {
return Rsx_Cache.get_instance(key);
}
// Returns from the pool of cached data for this 'instance'. An instance
// in this case is a virtual page load / navigation in the SPA. Call Main.lib.reset() to reset.
// Returns null on failure
static get_instance(key) {
if (Main.debug('no_api_cache')) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
if (typeof Core_Cache._caches.instance[key_encoded] != undef) {
return JSON.parse(Core_Cache._caches.instance[key_encoded]);
}
return null;
}
// Returns null on failure
// Returns a cached value from global cache (unique to page load, survives reset())
static get_global(key) {
if (Main.debug('no_api_cache')) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
if (typeof Core_Cache._caches.global[key_encoded] != undef) {
return JSON.parse(Core_Cache._caches.global[key_encoded]);
}
return null;
}
// Sets a value in instance and global cache (not shared between browser tabs)
static set(key, value) {
if (Main.debug('no_api_cache')) {
return;
}
if (value === null) {
return;
}
if (value.length > 64 * 1024) {
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
return;
}
let key_encoded = Rsx_Cache._encodekey(key);
Core_Cache._caches.global[key_encoded] = JSON.stringify(value);
Core_Cache._caches.instance[key_encoded] = JSON.stringify(value);
// Debugger.console_debug("CACHE", "Set", key, value);
Core_Cache._caches_set++;
// Reset cache after 5000 items set
if (Core_Cache._caches_set > 5000) {
// Get an accurate count
Core_Cache._caches_set = count(Core_Cache._caches.global);
if (Core_Cache._caches_set > 5000) {
Core_Cache._caches = {
global: {},
instance: {},
};
Core_Cache._caches_set = 0;
}
}
}
// Returns null on failure
// Returns a cached value from session cache (shared between browser tabs)
static get_session(key) {
if (Main.debug('no_api_cache')) {
return null;
}
if (!Rsx_Cache._supportsStorage()) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
let rs = sessionStorage.getItem(key_encoded);
if (!empty(rs)) {
return JSON.parse(rs);
} else {
return null;
}
}
// Sets a value in session cache (shared between browser tabs)
static set_session(key, value, _tryagain = true) {
if (Main.debug('no_api_cache')) {
return;
}
if (value.length > 64 * 1024) {
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
return;
}
if (!Rsx_Cache._supportsStorage()) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
try {
sessionStorage.removeItem(key_encoded);
sessionStorage.setItem(key_encoded, JSON.stringify(value));
} catch (e) {
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
sessionStorage.clear();
if (_tryagain) {
Core_Cache.set_session(key, value, false);
}
}
}
}
static _reset() {
Core_Cache._caches.instance = {};
}
/**
* For given key of any type including an object, return a string representing
* the key that the cached value should be stored as in sessionstorage
*/
static _encodekey(key) {
const prefix = 'cache_';
// Session reimplement
// var prefix = "cache_" + Spa.session().user_id() + "_";
if (is_string(key) && key.length < 150 && key.indexOf(' ') == -1) {
return prefix + Manifest.build_key() + '_' + key;
} else {
return prefix + hash([Manifest.build_key(), key]);
}
}
// Determines if sessionStorage is supported in the browser;
// result is cached for better performance instead of being run each time.
// Feature detection is based on how Modernizr does it;
// it's not straightforward due to FF4 issues.
// It's not run at parse-time as it takes 200ms in Android.
// Code from https://github.com/pamelafox/lscache/blob/master/lscache.js, Apache License Pamelafox
static _supportsStorage() {
let key = '__cachetest__';
let value = key;
if (Rsx_Cache.__supportsStorage !== undefined) {
return Rsx_Cache.__supportsStorage;
}
// some browsers will throw an error if you try to access local storage (e.g. brave browser)
// hence check is inside a try/catch
try {
if (!sessionStorage) {
return false;
}
} catch (ex) {
return false;
}
try {
sessionStorage.setItem(key, value);
sessionStorage.removeItem(key);
Rsx_Cache.__supportsStorage = true;
} catch (e) {
// If we hit the limit, and we don't have an empty sessionStorage then it means we have support
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
Rsx_Cache.__supportsStorage = true; // just maxed it out and even the set test failed.
} else {
Rsx_Cache.__supportsStorage = false;
}
}
return Rsx_Cache.__supportsStorage;
}
// Check to set if the error is us dealing with being out of space
static _isOutOfSpace(e) {
return e && (e.name === 'QUOTA_EXCEEDED_ERR' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || e.name === 'QuotaExceededError');
}
}

46
app/RSpade/Core/Js/Rsx_Init.js Executable file
View File

@@ -0,0 +1,46 @@
/**
* Rsx_Init - Core framework initialization and environment validation
*/
class Rsx_Init {
/**
* Called via Rsx._rsx_core_boot
* Initializes the core environment and runs basic sanity checks
*/
static _on_framework_core_init() {
if (!Rsx.is_prod()) {
Rsx_Init.__environment_checks();
}
}
/**
* Development environment checks to ensure proper configuration
*/
static __environment_checks() {
// Find all script tags in the DOM
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
// Skip inline scripts (no src attribute)
if (!script.src) {
continue;
}
// Check if script has defer attribute
if (!script.defer) {
const src = script.src || '(inline script)';
const reason = `All script tags used in an RSpade project must have defer attribute. Found script without defer: ${src}`;
// Stop framework boot with reason
Rsx._rsx_core_boot_stop(reason);
// Also log to console for visibility
console.error(`[RSX BOOT STOPPED] ${reason}`);
// Stop checking after first violation
return;
}
}
}
}

View File

@@ -0,0 +1,208 @@
// @JS-THIS-01-EXCEPTION
/**
* jQuery helper extensions for the RSX framework
* These extensions add utility methods to jQuery's prototype
* Note: 'this' references in jQuery extensions refer to jQuery objects by design
*/
class Rsx_Jq_Helpers {
/**
* Initialize jQuery extensions when the framework core is defined
* This method is called during framework initialization
*/
static _on_framework_core_define() {
// Returns true if jquery selector matched an element
$.fn.exists = function () {
return this.length > 0;
};
// Returns true if jquery element is visible
$.fn.is_visible = function () {
return this.is(':visible');
};
// Scrolls to the target element, only scrolls up. Todo: Create a version
// of this that also scrolls only down, or both
$.fn.scroll_up_to = function (speed = 0) {
if (!this.exists()) {
// console.warn("Could not find target element to scroll to");
return;
}
if (!this.is_in_dom()) {
// console.warn("Target element for scroll is not on dom");
return;
}
let e_top = Math.round(this.offset().top);
let s_top = $('body').scrollTop();
if (e_top < 0) {
let target = s_top + e_top;
$('html, body').animate(
{
scrollTop: target,
},
speed
);
}
};
// $().is(":focus") - check if element has focus
$.expr[':'].focus = function (elem) {
return elem === document.activeElement && (elem.type || elem.href);
};
// Save native click behavior before override
$.fn._click_native = $.fn.click;
// Override .click() to call preventDefault by default
// This prevents accidental page navigation/form submission - the correct behavior 95% of the time
$.fn.click = function (handler) {
// If no handler provided, trigger click event (jQuery .click() with no args)
if (typeof handler === 'undefined') {
return this._click_native();
}
// Attach click handler with automatic preventDefault
return this.on('click', function (e) {
// Save original preventDefault
const original_preventDefault = e.preventDefault.bind(e);
// Override preventDefault to show warning when called explicitly
e.preventDefault = function() {
console.warn('event.preventDefault() is called automatically by RSpade .click() handlers and can be removed.');
return original_preventDefault();
};
// Call preventDefault before handler
original_preventDefault();
return handler.call(this, e);
});
};
// Escape hatch: click handler without preventDefault for the 5% case
$.fn.click_allow_default = function (handler) {
if (typeof handler === 'undefined') {
return this._click_native();
}
return this._click_native(handler);
};
// Returns true if the jquery element exists in and is attached to the DOM
$.fn.is_in_dom = function () {
let $element = this;
let _ancestor = function (HTMLobj) {
while (HTMLobj.parentElement) {
HTMLobj = HTMLobj.parentElement;
}
return HTMLobj;
};
return _ancestor($element[0]) === document.documentElement;
};
// Returns true if the element is visible in the viewport
$.fn.is_in_viewport = function () {
let scrolltop = $(window).scrollTop() > 0 ? $(window).scrollTop() : $('body').scrollTop();
let $element = this;
const top_of_element = $element.offset().top;
const bottom_of_element = $element.offset().top + $element.outerHeight();
const bottom_of_screen = scrolltop + $(window).innerHeight();
const top_of_screen = scrolltop;
if (bottom_of_screen > top_of_element && top_of_screen < bottom_of_element) {
return true;
} else {
return false;
}
};
// Gets the tagname of a jquery element
$.fn.tagname = function () {
return this.prop('tagName').toLowerCase();
};
// Returns true if a href is not same domain
$.fn.is_external = function () {
const host = window.location.host;
const link = $('<a>', {
href: this.attr('href'),
})[0].hostname;
return link !== host;
};
// HTML5 form validation wrappers
$.fn.checkValidity = function () {
if (this.length === 0) return false;
return this[0].checkValidity();
};
$.fn.reportValidity = function () {
if (this.length === 0) return false;
return this[0].reportValidity();
};
$.fn.requestSubmit = function () {
if (this.length === 0) return this;
this[0].requestSubmit();
return this;
};
// Override $.ajax to prevent direct AJAX calls to local server
// Developers must use the Ajax endpoint pattern: await Controller.method(params)
const native_ajax = $.ajax;
$.ajax = function (url, options) {
// Handle both $.ajax(url, options) and $.ajax(options) signatures
let settings;
if (typeof url === 'string') {
settings = options || {};
settings.url = url;
} else {
settings = url || {};
}
// Check if this is a local request (relative URL or same domain)
const request_url = settings.url || '';
const is_relative = !request_url.match(/^https?:\/\//);
const is_same_domain = request_url.startsWith(window.location.origin);
const is_local_request = is_relative || is_same_domain;
// Allow framework Ajax.call() to function
if (settings.__local_integration === true) {
return native_ajax.call(this, settings);
}
// Block local AJAX requests that don't use the Ajax endpoint pattern
if (is_local_request) {
// Try to parse controller and action from URL
let controller_name = null;
let action_name = null;
const url_match = request_url.match(/\/_rsx_api\/([^\/]+)\/([^\/\?]+)/);
if (url_match) {
controller_name = url_match[1];
action_name = url_match[2];
}
let error_message = 'AJAX requests to localhost via $.ajax() are prohibited.\n\n';
if (controller_name && action_name) {
error_message += `Instead of:\n`;
error_message += ` $.ajax({url: '${request_url}', ...})\n\n`;
error_message += `Use:\n`;
error_message += ` await ${controller_name}.${action_name}(parameters)\n\n`;
} else {
error_message += `Use the Ajax endpoint pattern:\n`;
error_message += ` await Controller_Name.action_name(parameters)\n\n`;
}
error_message += `The controller method must have the #[Ajax_Endpoint] attribute.`;
shouldnt_happen(error_message);
}
// Allow external requests (different domain)
return native_ajax.call(this, settings);
};
}
}

View File

@@ -0,0 +1,185 @@
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Base class for JavaScript ORM models
*
* Provides core functionality for fetching records from backend PHP models.
* All model stubs generated by the manifest extend this base class.
*
* Example usage:
* // Fetch single record
* const user = await User_Model.fetch(123);
*
* // Fetch multiple records
* const users = await User_Model.fetch([1, 2, 3]);
*
* // Create instance with data
* const user = new User_Model({id: 1, name: 'John'});
*
* @Instantiatable
*/
class Rsx_Js_Model {
/**
* Constructor - Initialize model instance with data
*
* @param {Object} data - Key-value pairs to populate the model
*/
constructor(data = {}) {
// __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.
// PHP models add "__MODEL": "ClassName" to JSON, JavaScript uses it to create proper instances.
// This provides typed model objects instead of plain JSON, with methods and type checking.
// This constructor filters out the __MODEL marker that was used to identify which class
// to instantiate, keeping only the actual data properties on the instance.
const { __MODEL, ...modelData } = data;
Object.assign(this, modelData);
}
/**
* Fetch record(s) from the backend model
*
* This method mirrors the PHP Model::fetch() functionality.
* The backend model must have a fetch() method with the
* #[Ajax_Endpoint_Model_Fetch] annotation to be callable.
*
* @param {number|Array} id - Single ID or array of IDs to fetch
* @returns {Promise} - Single model instance, array of instances, or false
*/
static async fetch(id) {
const CurrentClass = this;
// Get the model class name from the current class
const modelName = CurrentClass.name;
const response = await $.ajax({
url: `/_fetch/${modelName}`,
method: 'POST',
data: { id: id },
dataType: 'json',
});
// Handle response based on type
if (response === false) {
return false;
}
// Use _instantiate_models_recursive to handle ORM instantiation
// This will automatically detect __MODEL properties and create appropriate instances
return Rsx_Js_Model._instantiate_models_recursive(response);
}
/**
* Get the model class name
* Used internally for API calls
*
* @returns {string} The class name
*/
static getModelName() {
const CurrentClass = this;
return CurrentClass.name;
}
/**
* Refresh this instance with latest data from server
*
* @returns {Promise} Updated instance or false if not found
*/
async refresh() {
const that = this;
if (!that.id) {
shouldnt_happen('Cannot refresh model without id property');
}
const fresh = await that.constructor.fetch(that.id);
if (fresh === false) {
return false;
}
// Update this instance with fresh data
Object.assign(that, fresh);
return that;
}
/**
* Convert model instance to plain object
* Useful for serialization or sending to APIs
*
* @returns {Object} Plain object representation
*/
toObject() {
const that = this;
const obj = {};
for (const key in that) {
if (that.hasOwnProperty(key) && typeof that[key] !== 'function') {
obj[key] = that[key];
}
}
return obj;
}
/**
* Convert model instance to JSON string
*
* @returns {string} JSON representation
*/
toJSON() {
const that = this;
return JSON.stringify(that.toObject());
}
/**
* Recursively instantiate ORM models in response data
*
* Looks for objects with __MODEL property and instantiates the appropriate
* JavaScript model class if it exists in the global scope.
*
* @param {*} data - The data to process (can be any type)
* @returns {*} The data with ORM objects instantiated
*/
static _instantiate_models_recursive(data) {
// __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.
// PHP models add "__MODEL": "ClassName" to JSON, JavaScript uses it to create proper instances.
// This provides typed model objects instead of plain JSON, with methods and type checking.
// This recursive processor scans all API response data looking for __MODEL markers.
// When found, it attempts to instantiate the appropriate JavaScript model class,
// converting {__MODEL: "User_Model", id: 1, name: "John"} into new User_Model({...}).
// Works recursively through arrays and nested objects to handle complex data structures.
// Handle null/undefined
if (data === null || data === undefined) {
return data;
}
// Handle arrays - recursively process each element
if (Array.isArray(data)) {
return data.map((item) => Rsx_Js_Model._instantiate_models_recursive(item));
}
// Handle objects
if (typeof data === 'object') {
// Check if this object has a __MODEL property
if (data.__MODEL && typeof data.__MODEL === 'string') {
// Try to find the model class in the global scope
const ModelClass = window[data.__MODEL];
// If the model class exists and extends Rsx_Js_Model, instantiate it
// Dynamic model resolution requires checking class existence - @JS-DEFENSIVE-01-EXCEPTION
if (ModelClass && ModelClass.prototype instanceof Rsx_Js_Model) {
return new ModelClass(data);
}
}
// Recursively process all object properties
const result = {};
for (const key in data) {
if (data.hasOwnProperty(key)) {
result[key] = Rsx_Js_Model._instantiate_models_recursive(data[key]);
}
}
return result;
}
// Return primitive values as-is
return data;
}
}

View File

@@ -0,0 +1,164 @@
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
/**
* Rsx_Route_Proxy - Type-safe route URL generator
*
* SPECIAL ARCHITECTURAL NOTE:
* This class intentionally diverges from RSX's standard static-only pattern.
* It uses instance properties and methods to enable the syntactic sugar:
* let url = Rsx.Route('Controller_Name', 'action_name').url();
*
* Each call to Rsx.Route() creates a new instance of Rsx_Route_Proxy that
* encapsulates the specific route pattern and required parameters. This
* allows for clean method chaining and parameter validation.
*
* WHY INSTANCE-BASED:
* - Encapsulates route-specific data (pattern, params) per instance
* - Enables fluent interface for URL generation
* - Provides type safety by validating params at runtime
* - Allows method chaining: .url(), .absolute_url(), .navigate()
*
* This divergence from static classes is appropriate here because each
* route proxy represents a specific route with its own state, not a
* collection of utility methods.
*
* @Instantiatable
*/
class Rsx_Route_Proxy {
/**
* Constructor
*
* @param {string} class_name The controller class name
* @param {string} method_name The action/method name
* @param {string} pattern The route pattern
*/
constructor(class_name, method_name, pattern) {
this._class = class_name;
this._method = method_name;
this._pattern = pattern;
this._required_params = [];
// Extract required parameters from the pattern
this._extract_required_params();
}
/**
* Extract required parameters from the route pattern
*/
_extract_required_params() {
const that = this;
// Match all :param patterns in the route
const matches = that._pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
// Remove the : prefix from each match
that._required_params = matches.map((match) => match.substring(1));
}
}
/**
* Check if this route matches the current controller and action
*
* @returns {boolean} True if this is the current route
*/
is_current() {
const that = this;
// Get current controller and action from window.rsxapp if available
return that._class === window.rsxapp.current_controller && that._method === window.rsxapp.current_action;
}
/**
* Generate a relative URL for the route
*
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated URL
* @throws {Error} If required parameters are missing
*/
url(params = {}) {
const that = this;
// Check if the method name starts with '#' - indicates unimplemented route
if (that._method.startsWith('#')) {
return '#';
}
// Check for required parameters
const missing = [];
for (const required of that._required_params) {
if (!(required in params)) {
missing.push(required);
}
}
if (missing.length > 0) {
throw new Error(
`Required parameters [${missing.join(', ')}] are missing for route ` + `${that._pattern} on ${that._class}::${that._method}`
);
}
// Build the URL by replacing parameters
let url = that._pattern;
const used_params = {};
for (const param_name of that._required_params) {
const value = params[param_name];
// URL encode the value
const encoded_value = encodeURIComponent(value);
url = url.replace(':' + param_name, encoded_value);
used_params[param_name] = true;
}
// Collect any extra parameters for query string
const query_params = {};
for (const key in params) {
if (!used_params[key]) {
query_params[key] = params[key];
}
}
// Append query string if there are extra parameters
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;
}
/**
* Generate an absolute URL for the route
*
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated absolute URL
* @throws {Error} If required parameters are missing
*/
absolute_url(params = {}) {
const that = this;
// Check if the method name starts with '#' - indicates unimplemented route
if (that._method.startsWith('#')) {
return '#';
}
// Get the relative URL first
const relative_url = that.url(params);
// Get the current protocol and host
const protocol = window.location.protocol;
const host = window.location.host; // includes port if non-standard
return protocol + '//' + host + relative_url;
}
/**
* Navigate to the route by setting window.location.href
*
* @param {Object} params Parameters to fill into the route
* @throws {Error} If required parameters are missing
*/
navigate(params = {}) {
const that = this;
const url = that.url(params);
window.location.href = url;
}
}

View File

@@ -0,0 +1,54 @@
/**
* View_Transitions - Smooth page-to-page transitions using View Transitions API
*
* Enables cross-document view transitions so the browser doesn't paint the new page
* until it's ready, creating smooth animations between pages.
*
* Falls back gracefully if View Transitions API is not available.
*/
class Rsx_View_Transitions {
/**
* Called during framework core init phase
* Checks for View Transitions API support and enables if available
*/
static _on_framework_core_init() {
// Check if View Transitions API is supported
if (!document.startViewTransition) {
console_debug('VIEW_TRANSITIONS', 'View Transitions API not supported, skipping');
return;
}
// Enable cross-document view transitions via CSS
Rsx_View_Transitions._inject_transition_css();
}
/**
* Inject CSS to enable cross-document view transitions
*
* The @view-transition { navigation: auto; } rule tells the browser to:
* 1. Capture a snapshot of the current page before navigation
* 2. Fetch the new page
* 3. Wait until the new page is fully loaded and painted (document.ready)
* 4. Animate smoothly between the two states
*
* This prevents the white flash during navigation and creates app-like transitions.
*/
static _inject_transition_css() {
const style = document.createElement('style');
style.textContent = `
@view-transition {
navigation: auto;
}
/* Disable animation - instant transition */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0s;
}
`;
document.head.appendChild(style);
}
}

161
app/RSpade/Core/Js/async.js Executable file
View File

@@ -0,0 +1,161 @@
/*
* Async utility functions for the RSpade framework.
* These functions handle asynchronous operations, delays, debouncing, and mutexes.
*/
// ============================================================================
// ASYNC UTILITIES
// ============================================================================
/**
* Pauses execution for specified milliseconds
* @param {number} [milliseconds=0] - Delay in milliseconds (0 uses requestAnimationFrame)
* @returns {Promise<void>} Promise that resolves after delay
* @example await sleep(1000); // Wait 1 second
*/
function sleep(milliseconds = 0) {
return new Promise((resolve) => {
if (milliseconds == 0 && requestAnimationFrame) {
requestAnimationFrame(resolve);
} else {
setTimeout(resolve, milliseconds);
}
});
}
/**
* Creates a debounced function with exclusivity and promise fan-in
*
* This function, when invoked, immediately runs the callback exclusively.
* For subsequent invocations, it applies a delay before running the callback exclusively again.
* The delay starts after the current asynchronous operation resolves.
*
* If 'delay' is set to 0, the function will only prevent enqueueing multiple executions of the
* same method more than once, but will still run them immediately in an exclusive sequential manner.
*
* The most recent invocation of the function will be the parameters that get passed to the function
* when it invokes.
*
* The function returns a promise that resolves when the next exclusive execution completes.
*
* @param {function} callback The callback function to be invoked
* @param {number} delay The delay in milliseconds before subsequent invocations
* @param {boolean} immediate if true, the first time the action is called, the callback executes immediately
* @returns {function} A function that when invoked, runs the callback immediately and exclusively,
*
* @decorator
*/
function debounce(callback, delay, immediate = false) {
let running = false;
let queued = false;
let last_end_time = 0; // timestamp of last completed run
let timer = null;
let next_args = [];
let resolve_queue = [];
let reject_queue = [];
const run_function = async () => {
const these_resolves = resolve_queue;
const these_rejects = reject_queue;
const args = next_args;
resolve_queue = [];
reject_queue = [];
next_args = [];
queued = false;
running = true;
try {
const result = await callback(...args);
for (const resolve of these_resolves) resolve(result);
} catch (err) {
for (const reject of these_rejects) reject(err);
} finally {
running = false;
last_end_time = Date.now();
if (queued) {
clearTimeout(timer);
timer = setTimeout(run_function, Math.max(delay, 0));
} else {
timer = null;
}
}
};
return function (...args) {
next_args = args;
return new Promise((resolve, reject) => {
resolve_queue.push(resolve);
reject_queue.push(reject);
// Nothing running and nothing scheduled
if (!running && !timer) {
const first_call = last_end_time === 0;
if (immediate && first_call) {
run_function();
return;
}
const since = first_call ? Infinity : Date.now() - last_end_time;
if (since >= delay) {
run_function();
} else {
const wait = Math.max(delay - since, 0);
clearTimeout(timer);
timer = setTimeout(run_function, wait);
}
return;
}
// If we're already running or a timer exists, just mark queued.
// The finally{} of run_function handles scheduling after full delay.
queued = true;
});
};
}
// ============================================================================
// READ-WRITE LOCK FUNCTIONS - Delegated to ReadWriteLock class
// ============================================================================
/**
* Acquire an exclusive write lock by name.
* Only one writer runs at a time; blocks readers until finished.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
function rwlock(name, cb) {
return ReadWriteLock.acquire(name, cb);
}
/**
* Acquire a shared read lock by name.
* Multiple readers run in parallel, but readers are blocked by queued/active writers.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
function rwlock_read(name, cb) {
return ReadWriteLock.acquire_read(name, cb);
}
/**
* Forcefully clear all locks and queues for a given name.
* @param {string} name
*/
function rwlock_force_unlock(name) {
ReadWriteLock.force_unlock(name);
}
/**
* Inspect lock state for debugging.
* @param {string} name
* @returns {{readers:number, writer_active:boolean, reader_q:number, writer_q:number}}
*/
function rwlock_pending(name) {
return ReadWriteLock.pending(name);
}

198
app/RSpade/Core/Js/browser.js Executable file
View File

@@ -0,0 +1,198 @@
/*
* Browser and DOM utility functions for the RSpade framework.
* These functions handle browser detection, viewport utilities, and DOM manipulation.
*/
// ============================================================================
// BROWSER DETECTION
// ============================================================================
/**
* Detects if user is on a mobile device or using mobile viewport
* @returns {boolean} True if mobile device or viewport < 992px
* @todo Improve user agent detection for all mobile devices
*/
function is_mobile() {
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
return true;
} else if ($(window).width() < 992) {
// 992px = bootstrap 4 col-md-
return true;
} else {
return false;
}
}
/**
* Detects if user is on desktop (not mobile)
* @returns {boolean} True if not mobile device/viewport
*/
function is_desktop() {
return !is_mobile();
}
/**
* Detects the user's operating system
* @returns {string} OS name: 'Mac OS', 'iPhone', 'iPad', 'Windows', 'Android-Phone', 'Android-Tablet', 'Linux', or 'Unknown'
*/
function get_os() {
let user_agent = window.navigator.userAgent,
platform = window.navigator.platform,
macos_platforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'],
windows_platforms = ['Win32', 'Win64', 'Windows', 'WinCE'],
ios_platforms = ['iPhone', 'iPad', 'iPod'],
os = null;
let is_mobile_device = is_mobile();
if (macos_platforms.indexOf(platform) !== -1) {
os = 'Mac OS';
} else if (ios_platforms.indexOf(platform) !== -1 && is_mobile_device) {
os = 'iPhone';
} else if (ios_platforms.indexOf(platform) !== -1 && !is_mobile_device) {
os = 'iPad';
} else if (windows_platforms.indexOf(platform) !== -1) {
os = 'Windows';
} else if (/Android/.test(user_agent) && is_mobile_device) {
os = 'Android-Phone';
} else if (/Android/.test(user_agent) && !is_mobile_device) {
os = 'Android-Tablet';
} else if (!os && /Linux/.test(platform)) {
os = 'Linux';
} else {
os = 'Unknown';
}
return os;
}
/**
* Detects if the user agent is a web crawler/bot
* @returns {boolean} True if user agent appears to be a bot/crawler
*/
function is_crawler() {
let user_agent = navigator.userAgent;
let bot_pattern = /bot|spider|crawl|slurp|archiver|ping|search|dig|tracker|monitor|snoopy|yahoo|baidu|msn|ask|teoma|axios/i;
return bot_pattern.test(user_agent);
}
// ============================================================================
// DOM SCROLLING UTILITIES
// ============================================================================
/**
* Scrolls parent container to make target element visible if needed
* @param {string|HTMLElement|jQuery} target - Target element to scroll into view
*/
function scroll_into_view_if_needed(target) {
const $target = $(target);
// Find the closest parent with overflow-y: auto
const $parent = $target.parent();
// Calculate the absolute top position of the target
const target_top = $target.position().top + $parent.scrollTop();
const target_height = $target.outerHeight();
const parent_height = $parent.height();
const scroll_position = $parent.scrollTop();
// Check if the target is out of view
if (target_top < scroll_position || target_top + target_height > scroll_position + parent_height) {
Debugger.console_debug('UI', 'Scrolling!', target_top);
// Calculate the new scroll position to center the target
let new_scroll_position = target_top + target_height / 2 - parent_height / 2;
// Limit the scroll position between 0 and the maximum scrollable height
new_scroll_position = Math.max(0, Math.min(new_scroll_position, $parent[0].scrollHeight - parent_height));
// Scroll the parent to the new scroll position
$parent.scrollTop(new_scroll_position);
}
}
/**
* Scrolls page to make target element visible if needed (with animation)
* @param {string|HTMLElement|jQuery} target - Target element to scroll into view
*/
function scroll_page_into_view_if_needed(target) {
const $target = $(target);
// Calculate the absolute top position of the target relative to the document
const target_top = $target.offset().top;
const target_height = $target.outerHeight();
const window_height = $(window).height();
const window_scroll_position = $(window).scrollTop();
// Check if the target is out of view
if (target_top < window_scroll_position || target_top + target_height > window_scroll_position + window_height) {
Debugger.console_debug('UI', 'Scrolling!', target_top);
// Calculate the new scroll position to center the target
const new_scroll_position = target_top + target_height / 2 - window_height / 2;
// Animate the scroll to the new position
$('html, body').animate(
{
scrollTop: new_scroll_position,
},
1000
); // duration of the scroll animation in milliseconds
}
}
// ============================================================================
// DOM UTILITIES
// ============================================================================
/**
* Waits for all images on the page to load
* @param {Function} callback - Function to call when all images are loaded
*/
function wait_for_images(callback) {
const $images = $('img'); // Get all img tags
const total_images = $images.length;
let images_loaded = 0;
if (total_images === 0) {
callback(); // if there are no images, immediately call the callback
}
$images.each(function () {
const img = new Image();
img.onload = function () {
images_loaded++;
if (images_loaded === total_images) {
callback(); // call the callback when all images are loaded
}
};
img.onerror = function () {
images_loaded++;
if (images_loaded === total_images) {
callback(); // also call the callback if an image fails to load
}
};
img.src = this.src; // this triggers the loading
});
}
/**
* Creates a jQuery element containing a non-breaking space
* @returns {jQuery} jQuery span element with &nbsp;
*/
function $nbsp() {
return $('<span>&nbsp;</span>');
}
/**
* Escapes special characters in a jQuery selector
* @param {string} id - Element ID to escape
* @returns {string} jQuery selector string with escaped special characters
* @warning Not safe for security-critical operations
*/
function escape_jq_selector(id) {
return '#' + id.replace(/(:|\.|\[|\]|,|=|@)/g, '\\$1');
}

28
app/RSpade/Core/Js/datetime.js Executable file
View File

@@ -0,0 +1,28 @@
/*
* Date and time utility functions for the RSpade framework.
* These functions handle date/time conversions and Unix timestamps.
*/
// ============================================================================
// DATE/TIME UTILITIES
// ============================================================================
/**
* Gets the current Unix timestamp (seconds since epoch)
* @returns {number} Current Unix timestamp in seconds
* @todo Calculate based on server time at page render
* @todo Move to a date library
*/
function unix_time() {
return Math.round(new Date().getTime() / 1000);
}
/**
* Converts a date string to Unix timestamp
* @param {string} str_date - Date string (Y-m-d H:i:s format)
* @returns {number} Unix timestamp in seconds
*/
function ymdhis_to_unix(str_date) {
const date = new Date(str_date);
return date.getTime() / 1000;
}

25
app/RSpade/Core/Js/decorator.js Executable file
View File

@@ -0,0 +1,25 @@
/**
* Decorator function that marks a function as a decorator implementation.
*
* When a function has @decorator in its JSDoc comment, it whitelists that function
* to be used as a decorator on other methods throughout the codebase.
*
* The function itself performs no operation - it simply returns its input unchanged.
* Its purpose is purely as a marker for the manifest validation system.
*
* Usage:
* // /**
* // * My custom decorator implementation
* // * @decorator
* // *\/
* function my_custom_decorator(target, key, descriptor) {
* // Decorator implementation
* }
*
* This allows my_custom_decorator to be used as @my_custom_decorator on static methods.
*
* TODO: This is probably no longer necessary? maybe?
*/
function decorator(value) {
return value;
}

81
app/RSpade/Core/Js/error.js Executable file
View File

@@ -0,0 +1,81 @@
/*
* Error handling utility functions for the RSpade framework.
* These functions handle error creation and debugging utilities.
*/
// ============================================================================
// ERROR HANDLING
// ============================================================================
/**
* Creates an error object from a string
* @param {string|Object} str - Error message or existing error object
* @param {number} [error_code] - Optional error status code
* @returns {Object} Error object with error and status properties
*/
function error(str, error_code) {
if (typeof str.error != undef) {
return str;
} else {
if (typeof error_code == undef) {
return { error: str, status: null };
} else {
return { error: str, status: error_code };
}
}
}
/**
* Sanity check failure handler for JavaScript
*
* This function should be called when a sanity check fails - i.e., when the code
* encounters a condition that "shouldn't happen" if everything is working correctly.
*
* Unlike PHP, we can't stop JavaScript execution, but we can:
* 1. Throw an error that will be caught by error handlers
* 2. Log a clear error to the console
* 3. Provide stack trace for debugging
*
* Use this instead of silently returning or continuing when encountering unexpected conditions.
*
* @param {string} message Optional specific message about what shouldn't have happened
* @throws {Error} Always throws with location and context information
*/
function shouldnt_happen(message = null) {
const error = new Error();
const stack = error.stack || '';
const stackLines = stack.split('\n');
// Get the caller location (skip the Error line and this function)
let callerInfo = 'unknown location';
if (stackLines.length > 2) {
const callerLine = stackLines[2] || stackLines[1] || '';
// Extract file and line number from stack trace
const match = callerLine.match(/at\s+.*?\s+\((.*?):(\d+):(\d+)\)/) || callerLine.match(/at\s+(.*?):(\d+):(\d+)/);
if (match) {
callerInfo = `${match[1]}:${match[2]}`;
}
}
let errorMessage = `Fatal: shouldnt_happen() was called at ${callerInfo}\n`;
errorMessage += 'This indicates a sanity check failed - the code is not behaving as expected.\n';
if (message) {
errorMessage += `Details: ${message}\n`;
}
errorMessage += 'Please thoroughly review the related code to determine why this error occurred.';
// Log to console with full visibility
console.error('='.repeat(80));
console.error('SANITY CHECK FAILURE');
console.error('='.repeat(80));
console.error(errorMessage);
console.error('Stack trace:', stack);
console.error('='.repeat(80));
// Throw error to stop execution flow
const fatalError = new Error(errorMessage);
fatalError.name = 'SanityCheckFailure';
throw fatalError;
}

438
app/RSpade/Core/Js/functions.js Executable file
View File

@@ -0,0 +1,438 @@
/*
* Core utility functions for the RSpade framework.
* These functions handle type checking, type conversion, string manipulation,
* and object/array utilities. They mirror functionality from PHP functions.
*
* Other utility functions are organized in:
* - async.js: Async utilities (sleep, debounce, mutex)
* - browser.js: Browser/DOM utilities (is_mobile, scroll functions)
* - datetime.js: Date/time utilities
* - hash.js: Hashing and comparison
* - error.js: Error handling
*/
// Todo: test that prod build identifies and removes uncalled functions from the final bundle.
// ============================================================================
// CONSTANTS AND HELPERS
// ============================================================================
// Define commonly used constants
const undef = 'undefined';
/**
* Iterates over arrays or objects with promise support
*
* Works with both synchronous and asynchronous callbacks. If the callback
* returns promises, they are executed in parallel and this function returns
* a promise that resolves when all parallel tasks complete.
*
* @param {Array|Object} obj - Collection to iterate
* @param {Function} callback - Function to call for each item (value, key) - can be async
* @returns {Promise|undefined} Promise if any callbacks return promises, undefined otherwise
*
* @example
* // Synchronous usage
* foreach([1,2,3], (val) => console.log(val));
*
* @example
* // Asynchronous usage - waits for all to complete
* await foreach([1,2,3], async (val) => {
* await fetch('/api/process/' + val);
* });
*/
function foreach(obj, callback) {
const results = [];
if (Array.isArray(obj)) {
obj.forEach((value, index) => {
results.push(callback(value, index));
});
} else if (obj && typeof obj === 'object') {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
results.push(callback(obj[key], key));
}
}
}
// Filter for promises
const promises = results.filter((result) => result && typeof result.then === 'function');
// If there are any promises, return Promise.all to wait for all to complete
if (promises.length > 0) {
return Promise.all(promises);
}
// No promises returned, so we're done
return undefined;
}
// ============================================================================
// TYPE CHECKING FUNCTIONS
// ============================================================================
/**
* Checks if a value is numeric
* @param {*} n - Value to check
* @returns {boolean} True if the value is a finite number
*/
function is_numeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
/**
* Checks if a value is a string
* @param {*} s - Value to check
* @returns {boolean} True if the value is a string
*/
function is_string(s) {
return typeof s == 'string';
}
/**
* Checks if a value is an integer
* @param {*} n - Value to check
* @returns {boolean} True if the value is an integer
*/
function is_integer(n) {
return Number.isInteger(n);
}
/**
* Checks if a value is a promise-like object
* @param {*} obj - Value to check
* @returns {boolean} True if the value has a then method
*/
function is_promise(obj) {
return typeof obj == 'object' && typeof obj.then == 'function';
}
/**
* Checks if a value is an array
* @param {*} obj - Value to check
* @returns {boolean} True if the value is an array
*/
function is_array(obj) {
return Array.isArray(obj);
}
/**
* Checks if a value is an object (excludes null)
* @param {*} obj - Value to check
* @returns {boolean} True if the value is an object and not null
*/
function is_object(obj) {
return typeof obj === 'object' && obj !== null;
}
/**
* Checks if a value is a function
* @param {*} function_to_check - Value to check
* @returns {boolean} True if the value is a function
*/
function is_function(function_to_check) {
return function_to_check && {}.toString.call(function_to_check) === '[object Function]';
}
/**
* Checks if a string is a valid email address
* Uses a practical RFC 5322 compliant regex that matches 99.99% of real-world email addresses
* @param {string} email - Email address to validate
* @returns {boolean} True if the string is a valid email address
*/
function is_email(email) {
if (!is_string(email)) {
return false;
}
const regex = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
return regex.test(email);
}
/**
* Checks if a value is defined (not undefined)
* @param {*} value - Value to check
* @returns {boolean} True if value is not undefined
*/
function isset(value) {
return typeof value != undef;
}
/**
* Checks if a value is empty (null, undefined, 0, "", empty array/object)
* @param {*} object - Value to check
* @returns {boolean} True if the value is considered empty
*/
function empty(object) {
if (typeof object == undef) {
return true;
}
if (object === null) {
return true;
}
if (typeof object == 'string' && object == '') {
return true;
}
if (typeof object == 'number') {
return object == 0;
}
if (Array.isArray(object)) {
return !object.length;
}
if (typeof object == 'function') {
return false;
}
for (let key in object) {
if (object.hasOwnProperty(key)) {
return false;
}
}
return true;
}
// ============================================================================
// TYPE CONVERSION FUNCTIONS
// ============================================================================
/**
* Converts a value to a floating point number
* Returns 0 for null, undefined, NaN, or non-numeric values
* @param {*} val - Value to convert
* @returns {number} Floating point number
*/
function float(val) {
// Handle null, undefined, empty string
if (val === null || val === undefined || val === '') {
return 0.0;
}
// Try to parse the value
const parsed = parseFloat(val);
// Check for NaN and return 0 if parsing failed
return isNaN(parsed) ? 0.0 : parsed;
}
/**
* Converts a value to an integer
* Returns 0 for null, undefined, NaN, or non-numeric values
* @param {*} val - Value to convert
* @returns {number} Integer value
*/
function int(val) {
// Handle null, undefined, empty string
if (val === null || val === undefined || val === '') {
return 0;
}
// Try to parse the value
const parsed = parseInt(val, 10);
// Check for NaN and return 0 if parsing failed
return isNaN(parsed) ? 0 : parsed;
}
/**
* Converts a value to a string
* Returns empty string for null or undefined
* @param {*} val - Value to convert
* @returns {string} String representation
*/
function str(val) {
// Handle null and undefined specially
if (val === null || val === undefined) {
return '';
}
// Convert to string
return String(val);
}
// ============================================================================
// STRING MANIPULATION FUNCTIONS
// ============================================================================
/**
* Escapes HTML special characters (uses Lodash escape)
* @param {string} str - String to escape
* @returns {string} HTML-escaped string
*/
function html(str) {
return _.escape(str);
}
/**
* Converts newlines to HTML line breaks
* @param {string} str - String to convert
* @returns {string} String with newlines replaced by <br />
*/
function nl2br(str) {
if (typeof str === undef || str === null) {
return '';
}
return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1<br />$2');
}
/**
* Escapes HTML and converts newlines to <br />
* @param {string} str - String to process
* @returns {string} HTML-escaped string with line breaks
*/
function htmlbr(str) {
return nl2br(html(str));
}
/**
* URL-encodes a string
* @param {string} str - String to encode
* @returns {string} URL-encoded string
*/
function urlencode(str) {
return encodeURIComponent(str);
}
/**
* URL-decodes a string
* @param {string} str - String to decode
* @returns {string} URL-decoded string
*/
function urldecode(str) {
return decodeURIComponent(str);
}
/**
* JSON-encodes a value
* @param {*} value - Value to encode
* @returns {string} JSON string
*/
function json_encode(value) {
return JSON.stringify(value);
}
/**
* JSON-decodes a string
* @param {string} str - JSON string to decode
* @returns {*} Decoded value
*/
function json_decode(str) {
return JSON.parse(str);
}
/**
* Console debug output with channel filtering
* Alias for Debugger.console_debug
* @param {string} channel - Debug channel name
* @param {...*} values - Values to log
*/
function console_debug(channel, ...values) {
Debugger.console_debug(channel, ...values);
}
/**
* Replaces all occurrences of a substring in a string
* @param {string} string - String to search in
* @param {string} search - Substring to find
* @param {string} replace - Replacement substring
* @returns {string} String with all occurrences replaced
*/
function replace_all(string, search, replace) {
if (!is_string(string)) {
string = string + '';
}
return string.split(search).join(replace);
}
/**
* Capitalizes the first letter of each word
* @param {string} input - String to capitalize
* @returns {string} String with first letter of each word capitalized
*/
function ucwords(input) {
return input
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// ============================================================================
// OBJECT AND ARRAY UTILITIES
// ============================================================================
/**
* Counts the number of properties in an object or elements in an array
* @param {Object|Array} o - Object or array to count
* @returns {number} Number of own properties/elements
*/
function count(o) {
let c = 0;
for (const k in o) {
if (o.hasOwnProperty(k)) {
++c;
}
}
return c;
}
/**
* Creates a shallow clone of an object, array, or function
* @param {*} obj - Value to clone
* @returns {*} Cloned value
*/
function clone(obj) {
if (typeof Function.prototype.__clone == undef) {
Function.prototype.__clone = function () {
//https://stackoverflow.com/questions/1833588/javascript-clone-a-function
const that = this;
let temp = function cloned() {
return that.apply(this, arguments);
};
for (let key in this) {
if (this.hasOwnProperty(key)) {
temp[key] = this[key];
}
}
return temp;
};
}
if (typeof obj == 'function') {
return obj.__clone();
} else if (obj.constructor && obj.constructor == Array) {
return obj.slice(0);
} else {
// https://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object/30042948#30042948
return Object.assign({}, obj);
}
}
/**
* Returns the first non-null/undefined value from arguments
* @param {...*} arguments - Values to check
* @returns {*} First non-null/undefined value, or null if none found
*/
function coalesce() {
let args = Array.from(arguments);
let return_val = null;
args.forEach(function (arg) {
if (return_val === null && typeof arg != undef && arg !== null) {
return_val = arg;
}
});
return return_val;
}
/**
* Converts CSV string to array, trimming each element
* @param {string} str_csv - CSV string to convert
* @returns {Array<string>} Array of trimmed values
* @todo Handle quoted/escaped characters
*/
function csv_to_array_trim(str_csv) {
const parts = str_csv.split(',');
const ret = [];
foreach(parts, (part) => {
ret.push(part.trim());
});
return ret;
}

116
app/RSpade/Core/Js/hash.js Executable file
View File

@@ -0,0 +1,116 @@
/*
* Hashing and comparison utility functions for the RSpade framework.
* These functions handle object hashing and deep comparison.
*/
// ============================================================================
// HASHING AND COMPARISON
// ============================================================================
/**
* Generates a unique hash for any value (handles objects, arrays, circular references)
* @param {*} the_var - Value to hash
* @param {boolean} [calc_sha1=true] - If true, returns SHA1 hash; if false, returns JSON
* @param {Array<string>} [ignored_keys=null] - Keys to ignore when hashing objects
* @returns {string} SHA1 hash or JSON string of the value
*/
function hash(the_var, calc_sha1 = true, ignored_keys = null) {
if (typeof the_var == undef) {
the_var = '__undefined__';
}
if (ignored_keys === null) {
ignored_keys = ['$'];
}
// Converts value to json, discarding circular references
let json_stringify_nocirc = function (value) {
const cache = [];
return JSON.stringify(value, function (key, v) {
if (typeof v === 'object' && typeof the_var._cache_key == 'function') {
return the_var._hash_key();
} else if (typeof v === 'object' && v !== null) {
if (cache.indexOf(v) !== -1) {
// Duplicate reference found, discard key
return;
}
cache.push(v);
}
return v;
});
};
// Turn every property and all its children into a single depth array of values that we can then
// sort and hash as a whole
let flat_var = {};
let _flatten = function (the_var, prefix, depth = 0) {
// If a class object is provided, circular references can make the call stack recursive.
// For the purposes of how the hash function is called, this should be sufficient.
if (depth > 10) {
return;
}
// Does not account for dates i think...
if (is_object(the_var) && typeof the_var._cache_key == 'function') {
// Use _cache_key to hash components
flat_var[prefix] = the_var._hash_key();
} else if (is_object(the_var) && typeof Abstract !== 'undefined' && the_var instanceof Abstract) {
// Stringify all class objects
flat_var[prefix] = json_stringify_nocirc(the_var);
} else if (is_object(the_var)) {
// Iterate other objects
flat_var[prefix] = {};
for (let k in the_var) {
if (the_var.hasOwnProperty(k) && ignored_keys.indexOf(k) == -1) {
_flatten(the_var[k], prefix + '..' + k, depth + 1);
}
}
} else if (is_array(the_var)) {
// Iterate arrays
flat_var[prefix] = [];
let i = 0;
foreach(the_var, (v) => {
_flatten(v, prefix + '..' + i, depth + 1);
i++;
});
} else if (is_function(the_var)) {
// nothing
} else if (!is_numeric(the_var)) {
flat_var[prefix] = String(the_var);
} else {
flat_var[prefix] = the_var;
}
};
_flatten(the_var, '_');
let sorter = [];
foreach(flat_var, function (v, k) {
sorter.push([k, v]);
});
sorter.sort(function (a, b) {
return a[0] > b[0];
});
let json = JSON.stringify(sorter);
if (calc_sha1) {
let hashed = sha1.sha1(json);
return hashed;
} else {
return json;
}
}
/**
* Deep comparison of two values (ignores property order and functions)
* @param {*} a - First value to compare
* @param {*} b - Second value to compare
* @returns {boolean} True if values are deeply equal
*/
function deep_equal(a, b) {
return hash(a, false) == hash(b, false);
}