wp_create_nonce('abj404_uninstall_nonce'),
'pluginSlug' => self::getPluginSlug(),
'redirectCount' => $redirectCount,
'i18n' => array(
'dialogTitle' => __('404 Solution - Deactivation Options', '404-solution'),
'btnCancel' => __('Cancel', '404-solution'),
'btnSkipFeedback' => __('Deactivate without feedback', '404-solution'),
'btnDeactivate' => __('Email Feedback & Deactivate', '404-solution'),
'btnSaving' => __('Processing...', '404-solution'),
'btnDeactivating' => __('Deactivating', '404-solution'),
)
));
// Custom CSS for modal styling
wp_add_inline_style('wp-jquery-ui-dialog', '
.abj404-uninstall-dialog .ui-dialog-titlebar {
background: #d63638;
color: white;
}
.abj404-uninstall-dialog .ui-dialog-titlebar-close {
color: white;
}
.abj404-uninstall-dialog .ui-dialog-titlebar-close:hover {
background: #b32d2e;
}
.abj404-uninstall-dialog .button-danger {
background: #d63638;
border-color: #d63638;
color: white;
}
.abj404-uninstall-dialog .button-danger:hover {
background: #b32d2e;
border-color: #b32d2e;
}
.abj404-uninstall-content label {
display: block;
margin: 8px 0;
cursor: pointer;
}
.abj404-uninstall-content label input[type="checkbox"],
.abj404-uninstall-content label input[type="radio"] {
margin-right: 8px;
}
.abj404-uninstall-content .description {
margin: 0;
color: #646970;
font-size: 12px;
}
.abj404-uninstall-content h3 {
margin-top: 18px;
margin-bottom: 8px;
border-bottom: 1px solid #dcdcde;
padding-bottom: 6px;
font-size: 14px;
}
.abj404-uninstall-content h3:first-child {
margin-top: 0;
}
.abj404-uninstall-reasons {
margin-left: 25px;
}
.abj404-uninstall-reasons label {
margin: 6px 0;
font-size: 13px;
}
.abj404-followup-section {
margin: 12px 0 !important;
padding: 12px !important;
}
.abj404-followup-section p {
margin: 0 0 8px 0 !important;
font-size: 13px !important;
}
.abj404-followup-section label {
margin: 4px 0 !important;
font-size: 13px !important;
}
');
// Output modal HTML in footer
add_action('admin_footer', array(__CLASS__, 'outputModalHTML'));
}
/**
* Output the modal HTML structure
*/
public static function outputModalHTML() {
$redirectCount = self::getRedirectCount();
?>
__('Insufficient permissions', '404-solution')));
return;
}
// Get preferences from AJAX request
// Use filter_var to properly handle boolean values sent from JavaScript
$preferences = array(
'delete_redirects' => isset($_POST['delete_redirects']) ? filter_var($_POST['delete_redirects'], FILTER_VALIDATE_BOOLEAN) : false,
'delete_logs' => isset($_POST['delete_logs']) ? filter_var($_POST['delete_logs'], FILTER_VALIDATE_BOOLEAN) : false,
'delete_cache' => true, // Always delete cache tables
'send_feedback' => isset($_POST['send_feedback']) ? filter_var($_POST['send_feedback'], FILTER_VALIDATE_BOOLEAN) : false,
'uninstall_reason' => isset($_POST['uninstall_reason']) ? sanitize_text_field($_POST['uninstall_reason']) : '',
'selected_issues' => isset($_POST['selected_issues']) ? sanitize_text_field($_POST['selected_issues']) : '',
'followup_details' => isset($_POST['followup_details']) ? sanitize_textarea_field($_POST['followup_details']) : '',
'better_plugin_name' => isset($_POST['better_plugin_name']) ? sanitize_text_field($_POST['better_plugin_name']) : '',
'other_reason_text' => isset($_POST['other_reason_text']) ? sanitize_textarea_field($_POST['other_reason_text']) : '',
'feedback_email' => isset($_POST['feedback_email']) ? sanitize_email($_POST['feedback_email']) : '',
'include_diagnostics' => isset($_POST['include_diagnostics']) ? filter_var($_POST['include_diagnostics'], FILTER_VALIDATE_BOOLEAN) : false
);
// Debug logging (only in debug mode to avoid logging PII like email/feedback in production)
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('404 Solution: AJAX handler received deactivation preferences');
error_log('404 Solution: Raw POST send_feedback = ' . (isset($_POST['send_feedback']) ? $_POST['send_feedback'] : 'NOT SET'));
error_log('404 Solution: Parsed send_feedback = ' . ($preferences['send_feedback'] ? 'true' : 'false'));
error_log('404 Solution: Parsed preferences: ' . print_r($preferences, true));
}
// Save preferences using site options for multisite compatibility
// In multisite, use site_option for network-activated plugins, regular option for single-site
$option_name = 'abj404_uninstall_preferences';
// Capture return value to verify save success
$save_result = false;
if (is_multisite() && self::isNetworkActivated()) {
// Network-activated: Use site option (accessible across all sites)
$save_result = update_site_option($option_name, $preferences);
} else {
// Single-site or site-specific activation: Use regular option
$save_result = update_option($option_name, $preferences, false); // autoload=false
}
// Verify the save was successful (false could mean unchanged OR failure)
if ($save_result === false) {
// Read back the option to verify it was actually saved
$saved_value = is_multisite() && self::isNetworkActivated()
? get_site_option($option_name)
: get_option($option_name);
// If the saved value doesn't match what we tried to save, it's a real failure
if ($saved_value !== $preferences) {
wp_send_json_error(array(
'message' => __('Could not save preferences. Your choices may not be preserved.', '404-solution')
));
return;
}
// If values match, the false return was just because value was unchanged (which is OK)
}
// Send feedback email only if user explicitly opted in
$should_send_email = $preferences['send_feedback'];
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('404 Solution: send_feedback=' . ($preferences['send_feedback'] ? 'true' : 'false') . ', should_send_email=' . ($should_send_email ? 'true' : 'false'));
}
$email_sent = false;
if ($should_send_email) {
$email_sent = self::sendFeedbackEmail($preferences);
}
// Build success message
if ($should_send_email) {
// User sent feedback - show appropriate message
$message = $email_sent
? __('Feedback sent successfully.', '404-solution')
: __('Feedback could not be sent.', '404-solution');
} else {
// User skipped feedback - minimal message (won't be shown anyway due to instant redirect)
$message = '';
}
// Return success (failures are already handled above)
wp_send_json_success(array('message' => $message));
}
/**
* Check if plugin is network-activated
*
* @return bool True if network-activated, false otherwise
*/
private static function isNetworkActivated() {
if (!is_multisite()) {
return false;
}
if (!function_exists('is_plugin_active_for_network')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return is_plugin_active_for_network(plugin_basename(ABJ404_FILE));
}
/**
* Get the plugin slug for JavaScript
*
* @return string Plugin directory slug
*/
private static function getPluginSlug() {
// Get plugin directory name from plugin file path
$pluginPath = plugin_basename(ABJ404_FILE);
$parts = explode('/', $pluginPath);
return $parts[0];
}
/**
* Get the count of redirects for display
*
* @return int Number of redirects
*/
private static function getRedirectCount() {
global $wpdb;
// Guard for test environment where DataAccess class may not be loaded
if (!class_exists('ABJ_404_Solution_DataAccess')) {
return 0;
}
$dao = ABJ_404_Solution_DataAccess::getInstance();
$table_name = $dao->getPrefixedTableName('abj404_redirects');
// Check if table exists
$table_exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) === $table_name;
if (!$table_exists) {
return 0;
}
$count = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE status != " . ABJ404_STATUS_TRASH);
return $count ? intval($count) : 0;
}
/**
* Get comprehensive plugin statistics for diagnostics.
* Includes redirect counts by type, captured 404s, log entries, and storage sizes.
*
* @return array Array with detailed plugin statistics
*/
private static function getPluginStatistics() {
$stats = array(
'redirects' => array('all' => 0, 'manual' => 0, 'auto' => 0, 'regex' => 0, 'trash' => 0),
'captured' => array('all' => 0, 'captured' => 0, 'ignored' => 0, 'later' => 0, 'trash' => 0),
'log_count' => 0,
'log_table_size_mb' => 0,
'debug_file_size_mb' => 0,
);
// Guard for test environment where DataAccess class may not be loaded
if (!class_exists('ABJ_404_Solution_DataAccess')) {
return $stats;
}
// Additional guard: check if wpdb has the required methods (test environments may use mocks)
global $wpdb;
if (!isset($wpdb) || !method_exists($wpdb, 'get_results')) {
return $stats;
}
try {
$dao = ABJ_404_Solution_DataAccess::getInstance();
// Get redirect counts by status
$redirectCounts = $dao->getRedirectStatusCounts(true);
if (is_array($redirectCounts)) {
$stats['redirects'] = $redirectCounts;
}
// Get captured 404s counts by status
$capturedCounts = $dao->getCapturedStatusCounts(true);
if (is_array($capturedCounts)) {
$stats['captured'] = $capturedCounts;
}
// Get log entry count
$stats['log_count'] = $dao->getLogsCount(0);
// Get log table size
$logTableSizeBytes = $dao->getLogDiskUsage();
if ($logTableSizeBytes > 0) {
$stats['log_table_size_mb'] = round($logTableSizeBytes / (1024 * 1024), 2);
}
// Get debug file size
if (class_exists('ABJ_404_Solution_Logging')) {
$logger = ABJ_404_Solution_Logging::getInstance();
$debugFilePath = $logger->getDebugFilePath();
if (file_exists($debugFilePath)) {
$debugFileSize = filesize($debugFilePath);
$stats['debug_file_size_mb'] = round($debugFileSize / (1024 * 1024), 2);
}
}
} catch (Exception $e) {
// Return defaults if there's any error
} catch (Error $e) {
// Also catch PHP Error for method not found, etc.
}
return $stats;
}
/**
* Get counts of categories, tags, pages, and posts for diagnostics.
* These counts help identify if memory issues are caused by large content volume.
*
* @return array Array with 'categories', 'tags', 'pages', 'posts' keys
*/
private static function getContentCounts() {
$counts = array(
'categories' => 0,
'tags' => 0,
'pages' => 0,
'posts' => 0,
);
// Guard for test environment where WordPress functions may not be available
if (!function_exists('wp_count_terms') || !function_exists('wp_count_posts')) {
return $counts;
}
// Count categories (includes product_cat for WooCommerce)
$category_count = wp_count_terms(array('taxonomy' => 'category', 'hide_empty' => false));
if (!is_wp_error($category_count)) {
$counts['categories'] = intval($category_count);
}
// Also count WooCommerce product categories if they exist
if (function_exists('taxonomy_exists') && taxonomy_exists('product_cat')) {
$product_cat_count = wp_count_terms(array('taxonomy' => 'product_cat', 'hide_empty' => false));
if (!is_wp_error($product_cat_count)) {
$counts['categories'] += intval($product_cat_count);
}
}
// Count tags (includes product_tag for WooCommerce)
$tag_count = wp_count_terms(array('taxonomy' => 'post_tag', 'hide_empty' => false));
if (!is_wp_error($tag_count)) {
$counts['tags'] = intval($tag_count);
}
// Also count WooCommerce product tags if they exist
if (function_exists('taxonomy_exists') && taxonomy_exists('product_tag')) {
$product_tag_count = wp_count_terms(array('taxonomy' => 'product_tag', 'hide_empty' => false));
if (!is_wp_error($product_tag_count)) {
$counts['tags'] += intval($product_tag_count);
}
}
// Count pages
$page_counts = wp_count_posts('page');
if ($page_counts && isset($page_counts->publish)) {
$counts['pages'] = intval($page_counts->publish);
}
// Count posts
$post_counts = wp_count_posts('post');
if ($post_counts && isset($post_counts->publish)) {
$counts['posts'] = intval($post_counts->publish);
}
// Also count WooCommerce products if they exist
if (function_exists('post_type_exists') && post_type_exists('product')) {
$product_counts = wp_count_posts('product');
if ($product_counts && isset($product_counts->publish)) {
$counts['posts'] += intval($product_counts->publish);
}
}
return $counts;
}
/**
* Send feedback email to plugin author
*
* @param array $preferences User preferences including feedback
* @return bool True if email sent successfully, false otherwise
*/
private static function sendFeedbackEmail($preferences) {
// Get site information
global $wp_version;
$site_name = get_bloginfo('name');
$admin_email = get_option('admin_email');
// Get plugin information
$plugin_stats = self::getPluginStatistics();
// Gather system information (excluding site URL for privacy)
$db_info = self::getDatabaseInfo();
$content_counts = self::getContentCounts();
$system_info = array(
'WordPress Version' => $wp_version,
'PHP Version' => phpversion(),
'Plugin Version' => defined('ABJ404_VERSION') ? ABJ404_VERSION : 'Unknown',
'MySQL Version' => $db_info['version'],
'DB Charset' => $db_info['charset'],
'DB Collation' => $db_info['collation'],
'Multisite' => is_multisite() ? 'Yes' : 'No',
'Active Plugins' => self::getActivePluginsList(),
'Category Count' => $content_counts['categories'],
'Tag Count' => $content_counts['tags'],
'Total Pages' => $content_counts['pages'],
'Total Posts' => $content_counts['posts'],
// Redirect counts
'Redirects (active)' => $plugin_stats['redirects']['all'],
' - Manual' => $plugin_stats['redirects']['manual'],
' - Automatic' => $plugin_stats['redirects']['auto'],
' - Regex' => $plugin_stats['redirects']['regex'],
' - Trashed' => $plugin_stats['redirects']['trash'],
// Captured 404s
'Captured 404s (active)' => $plugin_stats['captured']['all'],
' - New' => $plugin_stats['captured']['captured'],
' - Ignored' => $plugin_stats['captured']['ignored'],
' - Later' => $plugin_stats['captured']['later'],
' - Trash' => $plugin_stats['captured']['trash'],
// Database stats
'Log Entries in DB' => $plugin_stats['log_count'],
'Log Table Size' => $plugin_stats['log_table_size_mb'] . ' MB',
'Debug File Size' => $plugin_stats['debug_file_size_mb'] . ' MB',
);
// Build email subject
$subject = sprintf('[404 Solution] Deactivation Feedback from %s', $site_name);
// Build email body
$body = "Deactivation feedback received:\n\n";
$body .= "═══════════════════════════════════════\n";
$body .= "USER FEEDBACK\n";
$body .= "═══════════════════════════════════════\n\n";
if (!empty($preferences['uninstall_reason'])) {
$body .= "Reason: " . ucfirst(str_replace('-', ' ', $preferences['uninstall_reason'])) . "\n\n";
}
// Show selected issues (checkboxes)
if (!empty($preferences['selected_issues'])) {
$body .= "Specific Issues:\n";
$issues = explode(',', $preferences['selected_issues']);
foreach ($issues as $issue) {
$body .= " ☑ " . ucfirst(str_replace('-', ' ', $issue)) . "\n";
}
$body .= "\n";
}
// Show additional details from follow-up textarea
if (!empty($preferences['followup_details'])) {
$body .= "Additional Details:\n" . $preferences['followup_details'] . "\n\n";
}
// Show better plugin name if provided
if (!empty($preferences['better_plugin_name'])) {
$body .= "Switching to: " . $preferences['better_plugin_name'] . "\n\n";
}
// Show other reason details if provided
if (!empty($preferences['other_reason_text'])) {
$body .= "Other Reason Details:\n" . $preferences['other_reason_text'] . "\n\n";
}
if (!empty($preferences['feedback_email'])) {
$body .= "User Email: " . $preferences['feedback_email'] . "\n\n";
}
// Include diagnostics if user opted in
if (!empty($preferences['include_diagnostics'])) {
// Plugin debug log excerpt
$body .= "═══════════════════════════════════════\n";
$body .= "PLUGIN DEBUG LOG\n";
$body .= "═══════════════════════════════════════\n\n";
if (class_exists('ABJ_404_Solution_Logging')) {
try {
$logger = ABJ_404_Solution_Logging::getInstance();
$logExcerpt = $logger->getSanitizedLogExcerptForSupport();
$body .= $logExcerpt . "\n\n";
} catch (Exception $e) {
$body .= "Unable to retrieve log excerpt\n\n";
}
} else {
$body .= "Log excerpt unavailable (logging class not loaded)\n\n";
}
// Database collation snapshot to diagnose charset-related issues
$body .= "═══════════════════════════════════════\n";
$body .= "DATABASE COLLATIONS\n";
$body .= "═══════════════════════════════════════\n\n";
$body .= self::getDatabaseCollationSnapshot() . "\n\n";
// System information
$body .= "═══════════════════════════════════════\n";
$body .= "SYSTEM INFORMATION\n";
$body .= "═══════════════════════════════════════\n\n";
foreach ($system_info as $label => $value) {
$body .= sprintf("%-20s: %s\n", $label, $value);
}
}
$body .= "\n═══════════════════════════════════════\n";
$body .= "This feedback was sent automatically when the user deactivated the plugin.\n";
// Set email headers
$headers = array(
'Content-Type: text/plain; charset=UTF-8',
'From: ' . $site_name . ' <' . $admin_email . '>'
);
// Add reply-to if user provided email
if (!empty($preferences['feedback_email'])) {
$headers[] = 'Reply-To: ' . $preferences['feedback_email'];
}
// Send email to plugin author
$to = defined('ABJ404_AUTHOR_EMAIL') ? ABJ404_AUTHOR_EMAIL : '404solution@ajexperience.com';
// Log email attempt (only in debug mode)
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('404 Solution: Attempting to send feedback email to ' . $to);
error_log('404 Solution: Email subject: ' . $subject);
error_log('404 Solution: Feedback checkbox was checked: send_feedback=' . ($preferences['send_feedback'] ? 'true' : 'false'));
}
// Hook to log wp_mail failures (only in debug mode)
if (defined('WP_DEBUG') && WP_DEBUG) {
add_action('wp_mail_failed', function($error) {
error_log('404 Solution: wp_mail() FAILED - ' . $error->get_error_message());
});
}
$result = wp_mail($to, $subject, $body, $headers);
// Log result (only in debug mode)
if (defined('WP_DEBUG') && WP_DEBUG) {
if ($result) {
error_log('404 Solution: wp_mail() returned TRUE - email sent successfully');
} else {
error_log('404 Solution: wp_mail() returned FALSE - email send failed');
}
}
return $result;
}
/**
* Get list of active plugins
*
* @return string Comma-separated list of active plugin names
*/
private static function getActivePluginsList() {
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$all_plugins = get_plugins();
$active_plugins = get_option('active_plugins', array());
$active_plugin_names = array();
foreach ($active_plugins as $plugin_path) {
if (isset($all_plugins[$plugin_path])) {
$active_plugin_names[] = $all_plugins[$plugin_path]['Name'];
}
}
return !empty($active_plugin_names)
? implode(', ', array_slice($active_plugin_names, 0, 10)) . (count($active_plugin_names) > 10 ? '...' : '')
: 'None';
}
/**
* Get database version and charset info for diagnostics.
* Uses fallback chain for locked-down hosts.
*
* @return array Array with 'version', 'charset', and 'collation' keys
*/
private static function getDatabaseInfo() {
global $wpdb;
$info = array(
'version' => 'Unknown',
'charset' => 'Unknown',
'collation' => 'Unknown',
);
// Get MySQL/MariaDB version
$version = $wpdb->get_var("SELECT VERSION()");
if ($version) {
$info['version'] = $version;
}
// Get database default charset and collation
if (!defined('DB_NAME')) {
// Test environment - use wpdb defaults
$info['charset'] = $wpdb->charset ?: 'utf8mb4';
$info['collation'] = $wpdb->collate ?: 'utf8mb4_unicode_ci';
return $info;
}
// Try information_schema.SCHEMATA first
$db_name = DB_NAME;
$charset_query = $wpdb->prepare(
"SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME " .
"FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
$db_name
);
$db_result = $wpdb->get_row($charset_query, ARRAY_A);
if ($db_result && !empty($db_result['DEFAULT_CHARACTER_SET_NAME'])) {
$info['charset'] = $db_result['DEFAULT_CHARACTER_SET_NAME'];
$info['collation'] = $db_result['DEFAULT_COLLATION_NAME'] ?? 'Unknown';
return $info;
}
// Fallback: SHOW VARIABLES for character_set_database and collation_database
$charset_result = $wpdb->get_row("SHOW VARIABLES LIKE 'character_set_database'", ARRAY_A);
$collation_result = $wpdb->get_row("SHOW VARIABLES LIKE 'collation_database'", ARRAY_A);
if ($charset_result && isset($charset_result['Value'])) {
$info['charset'] = $charset_result['Value'];
}
if ($collation_result && isset($collation_result['Value'])) {
$info['collation'] = $collation_result['Value'];
}
// Final fallback: WordPress connection settings
if ($info['charset'] === 'Unknown') {
$info['charset'] = $wpdb->charset ?: (defined('DB_CHARSET') ? DB_CHARSET : 'utf8mb4');
}
if ($info['collation'] === 'Unknown') {
$info['collation'] = $wpdb->collate ?: 'utf8mb4_unicode_ci';
}
return $info;
}
/**
* Capture charset/collation details for key plugin tables.
*
* @return string Human-readable summary for email diagnostics
*/
private static function getDatabaseCollationSnapshot() {
global $wpdb;
$summaryLines = array();
// Show the table prefix to help diagnose prefix mismatch issues
$summaryLines[] = "Table prefix: " . $wpdb->prefix;
$summaryLines[] = "";
// Safely get class instances - may not exist in test environment
if (!class_exists('ABJ_404_Solution_DatabaseUpgradesEtc') ||
!class_exists('ABJ_404_Solution_DataAccess')) {
$summaryLines[] = "Collation details unavailable (required classes not loaded).";
return implode("\n", $summaryLines);
}
$dbUtils = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
$dao = ABJ_404_Solution_DataAccess::getInstance();
// Get baseline from wp_posts
$targetTable = $wpdb->prefix . 'posts';
$targetInfo = self::getTableInfo($targetTable);
if ($targetInfo === null || isset($targetInfo['error'])) {
$errorMsg = isset($targetInfo['error']) ? $targetInfo['error'] : 'table not found';
$summaryLines[] = "Could not read collation for {$targetTable} (baseline): {$errorMsg}";
return implode("\n", $summaryLines);
}
$targetCollation = $targetInfo['collation'];
$targetCharset = $targetInfo['charset'];
$targetEngine = $targetInfo['engine'];
$summaryLines[] = sprintf(
"%s -> %s / %s / %s (baseline)",
$targetTable,
$targetCharset,
$targetCollation,
$targetEngine
);
$pluginTables = array(
'Logs' => $dao->doTableNameReplacements("{wp_abj404_logsv2}"),
'Lookup' => $dao->doTableNameReplacements("{wp_abj404_lookup}"),
'Permalink Cache' => $dao->doTableNameReplacements("{wp_abj404_permalink_cache}"),
'Spelling Cache' => $dao->doTableNameReplacements("{wp_abj404_spelling_cache}"),
'Redirects' => $dao->doTableNameReplacements("{wp_abj404_redirects}"),
);
foreach ($pluginTables as $label => $tableName) {
$tableInfo = self::getTableInfo($tableName);
if ($tableInfo === null) {
$summaryLines[] = sprintf(
"%s (%s) -> unavailable (table not found)",
$label,
$tableName
);
continue;
}
if (isset($tableInfo['error'])) {
$summaryLines[] = sprintf(
"%s (%s) -> unavailable (%s)",
$label,
$tableName,
$tableInfo['error']
);
continue;
}
$collation = $tableInfo['collation'];
$charset = $tableInfo['charset'];
$engine = $tableInfo['engine'];
$matchesBaseline = ($collation === $targetCollation && $charset === $targetCharset);
$utf8mb4Note = (stripos($charset, 'utf8mb4') === false) ? ' [non-utf8mb4]' : '';
$matchNote = $matchesBaseline ? 'matches' : 'DIFFERS';
$summaryLines[] = sprintf(
"%s (%s) -> %s / %s / %s (%s)%s",
$label,
$tableName,
$charset,
$collation,
$engine,
$matchNote,
$utf8mb4Note
);
}
return implode("\n", $summaryLines);
}
/**
* Get table info with fallback chain for locked-down hosts.
*
* Tries multiple methods in order:
* 1. information_schema (most complete)
* 2. SHOW TABLE STATUS (widely permitted)
* 3. SHOW CREATE TABLE (parse DDL)
* 4. WordPress globals (connection-level defaults)
*
* @param string $tableName Table name to look up
* @return array Array with 'charset', 'collation', 'engine' keys
*/
private static function getTableInfo($tableName) {
// Try information_schema first (most complete data)
$result = self::tryInformationSchema($tableName);
if ($result !== null && !isset($result['error'])) {
return $result;
}
// Fallback: SHOW TABLE STATUS
$result = self::tryShowTableStatus($tableName);
if ($result !== null && !isset($result['error'])) {
return $result;
}
// Fallback: SHOW CREATE TABLE
$result = self::tryShowCreateTable($tableName);
if ($result !== null && !isset($result['error'])) {
return $result;
}
// Final fallback: WordPress connection defaults
return self::getWpdbDefaults();
}
/**
* Try to get table info from information_schema.
*
* @param string $tableName Table name to look up
* @return array|null Array with 'charset', 'collation', 'engine' keys, or null/error array on failure
*/
private static function tryInformationSchema($tableName) {
global $wpdb;
// Guard for test environment where wpdb may be a minimal mock
if (!method_exists($wpdb, 'get_row')) {
return array('error' => 'wpdb methods unavailable');
}
$query = $wpdb->prepare(
"SELECT TABLE_COLLATION, ENGINE, " .
"SUBSTRING_INDEX(TABLE_COLLATION, '_', 1) as TABLE_CHARSET " .
"FROM information_schema.tables " .
"WHERE TABLE_NAME = %s AND TABLE_SCHEMA = DATABASE()",
$tableName
);
$result = $wpdb->get_row($query, ARRAY_A);
// Check for query error
if (!empty($wpdb->last_error)) {
// Check for permission-related errors
if (stripos($wpdb->last_error, 'denied') !== false ||
stripos($wpdb->last_error, 'permission') !== false) {
return array('error' => 'permission denied');
}
return array('error' => 'query error');
}
// Table not found
if (empty($result)) {
return null;
}
// Handle case variations in column names
$result = array_change_key_case($result, CASE_UPPER);
$collation = $result['TABLE_COLLATION'] ?? null;
$engine = $result['ENGINE'] ?? 'Unknown';
$charset = $result['TABLE_CHARSET'] ?? null;
// Fallback charset extraction from collation
if (empty($charset) && !empty($collation)) {
$charset = explode('_', $collation)[0];
}
if (empty($collation)) {
return array('error' => 'no collation data');
}
return array(
'charset' => $charset,
'collation' => $collation,
'engine' => $engine
);
}
/**
* Try to get table info using SHOW TABLE STATUS.
*
* @param string $tableName Table name to look up
* @return array|null Array with 'charset', 'collation', 'engine' keys, or null/error on failure
*/
private static function tryShowTableStatus($tableName) {
global $wpdb;
if (!method_exists($wpdb, 'get_row')) {
return array('error' => 'wpdb methods unavailable');
}
// SHOW TABLE STATUS LIKE requires the table name without database prefix matching
$result = $wpdb->get_row(
$wpdb->prepare("SHOW TABLE STATUS LIKE %s", $tableName),
ARRAY_A
);
if (!empty($wpdb->last_error)) {
return array('error' => 'SHOW TABLE STATUS failed');
}
if (empty($result)) {
return null;
}
$collation = $result['Collation'] ?? null;
$engine = $result['Engine'] ?? 'Unknown';
$charset = $collation ? explode('_', $collation)[0] : null;
if (empty($collation)) {
return null;
}
return array(
'charset' => $charset,
'collation' => $collation,
'engine' => $engine
);
}
/**
* Try to get table info by parsing SHOW CREATE TABLE output.
*
* @param string $tableName Table name to look up
* @return array|null Array with 'charset', 'collation', 'engine' keys, or null on failure
*/
private static function tryShowCreateTable($tableName) {
global $wpdb;
if (!method_exists($wpdb, 'get_row')) {
return null;
}
// Use backticks to safely quote table name
$result = $wpdb->get_row("SHOW CREATE TABLE `" . esc_sql($tableName) . "`", ARRAY_N);
if (empty($result[1])) {
return null;
}
$ddl = $result[1];
// Match charset: CHARSET=utf8mb4, DEFAULT CHARSET=utf8mb4, CHARACTER SET utf8mb4
preg_match('/(?:DEFAULT\s+)?(?:CHARSET|CHARACTER\s+SET)(?:\s*=\s*|\s+)([\w\d]+)/i', $ddl, $charsetMatch);
// Match collation: COLLATE=utf8mb4_unicode_ci, COLLATE utf8mb4_unicode_ci
preg_match('/(?:DEFAULT\s+)?COLLATE(?:\s*=\s*|\s+)([\w\d_]+)/i', $ddl, $collationMatch);
// Match engine: ENGINE=InnoDB
preg_match('/ENGINE\s*=\s*([\w]+)/i', $ddl, $engineMatch);
$charset = $charsetMatch[1] ?? null;
$collation = $collationMatch[1] ?? null;
$engine = $engineMatch[1] ?? 'Unknown';
// Derive collation from charset if not explicit
if ($charset && !$collation) {
$collation = $charset . '_general_ci';
}
// Need at least charset or collation to return valid data
if (empty($charset) && empty($collation)) {
return null;
}
return array(
'charset' => $charset ?: explode('_', $collation)[0],
'collation' => $collation,
'engine' => $engine
);
}
/**
* Get WordPress connection-level charset/collation as final fallback.
*
* @return array Array with 'charset', 'collation', 'engine', 'source' keys
*/
private static function getWpdbDefaults() {
global $wpdb;
$charset = 'utf8mb4';
$collation = 'utf8mb4_unicode_ci';
// Try to get from wpdb properties
if (isset($wpdb->charset) && !empty($wpdb->charset)) {
$charset = $wpdb->charset;
} elseif (defined('DB_CHARSET') && DB_CHARSET) {
$charset = DB_CHARSET;
}
if (isset($wpdb->collate) && !empty($wpdb->collate)) {
$collation = $wpdb->collate;
}
return array(
'charset' => $charset,
'collation' => $collation,
'engine' => 'Unknown',
'source' => 'wpdb defaults'
);
}
}