519 lines
24 KiB
PHP
519 lines
24 KiB
PHP
<?php
|
||
/**
|
||
* Plugin Name: Mojibake Repair (Temporary)
|
||
* Description: Previews and repairs Czech text and punctuation mojibake while preserving serialized WordPress settings.
|
||
* Version: 1.1.1
|
||
* Requires PHP: 7.4
|
||
*/
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
final class WP_Mojibake_Repair_Temporary
|
||
{
|
||
const PAGE = 'wp-mojibake-repair';
|
||
const SAMPLE_LIMIT = 5;
|
||
const SAMPLE_LENGTH = 300;
|
||
const APPROVAL_TTL = 600;
|
||
const REPAIR_VERSION = 3;
|
||
const BATCH_SIZE = 200;
|
||
|
||
public static function init()
|
||
{
|
||
add_action('admin_menu', array(__CLASS__, 'add_page'));
|
||
}
|
||
|
||
public static function add_page()
|
||
{
|
||
add_management_page(
|
||
'Mojibake Repair',
|
||
'Mojibake Repair',
|
||
'manage_options',
|
||
self::PAGE,
|
||
array(__CLASS__, 'render_page')
|
||
);
|
||
}
|
||
|
||
public static function render_page()
|
||
{
|
||
if (!current_user_can('manage_options')) {
|
||
wp_die('You are not allowed to run this repair.');
|
||
}
|
||
|
||
global $wpdb;
|
||
|
||
$plan = self::plan($wpdb->prefix);
|
||
$schema = self::inspect_schema($plan);
|
||
$action = isset($_POST['wpmr_action']) ? sanitize_key(wp_unslash($_POST['wpmr_action'])) : '';
|
||
$result = null;
|
||
$preview = null;
|
||
$error = '';
|
||
|
||
if ($action !== '') {
|
||
check_admin_referer('wpmr_action', 'wpmr_nonce');
|
||
try {
|
||
self::require_safe_schema($schema);
|
||
|
||
if ($action === 'preview') {
|
||
$preview = self::preview($plan);
|
||
} elseif ($action === 'execute') {
|
||
if (empty($_POST['confirm_backup'])) {
|
||
throw new RuntimeException('Confirm that you have a restorable backup.');
|
||
}
|
||
$approval = self::verify_approval(
|
||
isset($_POST['approval']) ? wp_unslash($_POST['approval']) : '',
|
||
$plan
|
||
);
|
||
$result = self::execute($plan, $approval);
|
||
}
|
||
} catch (Throwable $e) {
|
||
$error = $e->getMessage();
|
||
}
|
||
}
|
||
|
||
echo '<div class="wrap"><h1>Mojibake Repair</h1>';
|
||
echo '<div class="notice notice-error"><p><strong>Temporary repair plugin.</strong> Keep your backup. Review the preview carefully. Run once, verify the site, then deactivate and delete this plugin.</p></div>';
|
||
echo '<p><strong>Database:</strong> <code>' . esc_html(DB_NAME) . '</code> <strong>Prefix:</strong> <code>' . esc_html($wpdb->prefix) . '</code></p>';
|
||
|
||
self::render_schema($schema);
|
||
|
||
if ($error !== '') {
|
||
echo '<div class="notice notice-error"><p><strong>Stopped:</strong> ' . esc_html($error) . '</p></div>';
|
||
}
|
||
|
||
if ($result !== null) {
|
||
self::render_result($result);
|
||
} elseif ($preview !== null) {
|
||
self::render_preview($preview, $plan);
|
||
} elseif ($action === '') {
|
||
echo '<h2>Step 1: Preview</h2>';
|
||
echo '<p>This runs SELECT queries only and shows up to ' . self::SAMPLE_LIMIT . ' changed examples per column.</p>';
|
||
echo '<form method="post">';
|
||
wp_nonce_field('wpmr_action', 'wpmr_nonce');
|
||
echo '<input type="hidden" name="wpmr_action" value="preview">';
|
||
submit_button('Generate read-only preview', 'primary', 'submit', false);
|
||
echo '</form>';
|
||
}
|
||
|
||
echo '</div>';
|
||
}
|
||
|
||
private static function plan($prefix)
|
||
{
|
||
if (!preg_match('/\A[A-Za-z0-9_]+\z/', $prefix)) {
|
||
throw new RuntimeException('The WordPress table prefix is not a safe SQL identifier.');
|
||
}
|
||
|
||
return array(
|
||
$prefix . 'posts' => array('key' => 'ID', 'columns' => array('post_content', 'post_title', 'post_excerpt')),
|
||
$prefix . 'postmeta' => array('key' => 'meta_id', 'columns' => array('meta_value')),
|
||
$prefix . 'options' => array('key' => 'option_id', 'columns' => array('option_value')),
|
||
$prefix . 'comments' => array('key' => 'comment_ID', 'columns' => array('comment_author', 'comment_content')),
|
||
$prefix . 'commentmeta' => array('key' => 'meta_id', 'columns' => array('meta_value')),
|
||
$prefix . 'terms' => array('key' => 'term_id', 'columns' => array('name', 'slug')),
|
||
$prefix . 'term_taxonomy'=> array('key' => 'term_taxonomy_id', 'columns' => array('description')),
|
||
$prefix . 'users' => array('key' => 'ID', 'columns' => array('display_name')),
|
||
$prefix . 'usermeta' => array('key' => 'umeta_id', 'columns' => array('meta_value')),
|
||
);
|
||
}
|
||
|
||
private static function inspect_schema($plan)
|
||
{
|
||
global $wpdb;
|
||
$schema = array();
|
||
|
||
foreach ($plan as $table => $definition) {
|
||
$row = $wpdb->get_row($wpdb->prepare(
|
||
'SELECT TABLE_TYPE, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s',
|
||
DB_NAME,
|
||
$table
|
||
), ARRAY_A);
|
||
|
||
$columns = $wpdb->get_col($wpdb->prepare(
|
||
'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s',
|
||
DB_NAME,
|
||
$table
|
||
));
|
||
|
||
$required = array_merge(array($definition['key']), $definition['columns']);
|
||
$missing = array_values(array_diff($required, $columns));
|
||
$exists = is_array($row) && isset($row['TABLE_TYPE']) && $row['TABLE_TYPE'] === 'BASE TABLE';
|
||
$engine = $exists && isset($row['ENGINE']) ? $row['ENGINE'] : '';
|
||
|
||
$schema[$table] = array(
|
||
'columns' => $definition['columns'],
|
||
'exists' => $exists,
|
||
'engine' => $engine,
|
||
'missing' => $missing,
|
||
'ready' => $exists && empty($missing) && strcasecmp($engine, 'InnoDB') === 0,
|
||
);
|
||
}
|
||
|
||
return $schema;
|
||
}
|
||
|
||
private static function require_safe_schema($schema)
|
||
{
|
||
foreach ($schema as $table => $status) {
|
||
if (!$status['exists']) {
|
||
throw new RuntimeException($table . ' is missing or is not a base table.');
|
||
}
|
||
if (!empty($status['missing'])) {
|
||
throw new RuntimeException($table . ' is missing: ' . implode(', ', $status['missing']) . '.');
|
||
}
|
||
if (strcasecmp($status['engine'], 'InnoDB') !== 0) {
|
||
throw new RuntimeException($table . ' uses ' . $status['engine'] . ', not InnoDB. Repair was blocked because rollback cannot be guaranteed.');
|
||
}
|
||
}
|
||
}
|
||
|
||
private static function preview($plan, $write = false)
|
||
{
|
||
global $wpdb;
|
||
$preview = array('columns' => array(), 'counts' => array(), 'fingerprints' => array(), 'total' => 0, 'skipped' => 0);
|
||
|
||
foreach ($plan as $table => $definition) {
|
||
foreach ($definition['columns'] as $column) {
|
||
$table_sql = self::identifier($table);
|
||
$key_sql = self::identifier($definition['key']);
|
||
$column_sql = self::identifier($column);
|
||
$id = $table . '.' . $column;
|
||
$count = 0;
|
||
$skipped = 0;
|
||
$samples = array();
|
||
$skipped_ids = array();
|
||
$cursor = null;
|
||
$hash = hash_init('sha256');
|
||
|
||
do {
|
||
$where = $cursor === null ? '' : $wpdb->prepare("WHERE {$key_sql} > %d", $cursor);
|
||
$limit = self::BATCH_SIZE;
|
||
$batch = $wpdb->get_results(
|
||
"SELECT {$key_sql} AS row_id, {$column_sql} AS before_value FROM {$table_sql} {$where} ORDER BY {$key_sql} ASC LIMIT {$limit}",
|
||
ARRAY_A
|
||
);
|
||
self::throw_on_db_error();
|
||
foreach ($batch as $row) {
|
||
$cursor = $row['row_id'];
|
||
if ($row['before_value'] === null) {
|
||
continue;
|
||
}
|
||
$before = (string) $row['before_value'];
|
||
try {
|
||
$after = self::repair_value($before);
|
||
} catch (RuntimeException $e) {
|
||
$skipped++;
|
||
if (count($skipped_ids) < self::SAMPLE_LIMIT) {
|
||
$skipped_ids[] = (string) $cursor;
|
||
}
|
||
continue;
|
||
}
|
||
if ($after === $before) {
|
||
continue;
|
||
}
|
||
// Length framing makes the approval cover exact IDs and full values.
|
||
foreach (array((string) $cursor, $before, $after) as $part) {
|
||
hash_update($hash, strlen($part) . ':' . $part);
|
||
}
|
||
$count++;
|
||
if (count($samples) < self::SAMPLE_LIMIT) {
|
||
$samples[] = array('row_id' => $cursor, 'before_value' => self::sample($before), 'after_value' => self::sample($after));
|
||
}
|
||
if ($write) {
|
||
// Bypass option/meta APIs to preserve JSON escaping and serialization.
|
||
// Refuse to overwrite a value changed since it was read.
|
||
$affected = $wpdb->query($wpdb->prepare(
|
||
"UPDATE {$table_sql} SET {$column_sql} = %s WHERE {$key_sql} = %d AND BINARY {$column_sql} = BINARY %s",
|
||
$after,
|
||
$cursor,
|
||
$before
|
||
));
|
||
self::throw_on_db_error();
|
||
if ($affected !== 1) {
|
||
throw new RuntimeException('A value changed while updating ' . $id . '. Generate a fresh preview.');
|
||
}
|
||
}
|
||
}
|
||
} while (count($batch) === self::BATCH_SIZE);
|
||
|
||
$preview['columns'][] = array(
|
||
'table' => $table, 'column' => $column, 'count' => $count,
|
||
'skipped' => $skipped, 'skipped_ids' => $skipped_ids, 'samples' => $samples,
|
||
);
|
||
$preview['counts'][$id] = $count;
|
||
$preview['fingerprints'][$id] = hash_final($hash);
|
||
$preview['total'] += $count;
|
||
$preview['skipped'] += $skipped;
|
||
}
|
||
}
|
||
return $preview;
|
||
}
|
||
|
||
private static function sample($value)
|
||
{
|
||
// Avoid cutting a UTF-8 character in the preview (no mbstring required).
|
||
preg_match('/\A.{0,' . self::SAMPLE_LENGTH . '}/us', $value, $match);
|
||
return isset($match[0]) ? $match[0] : '';
|
||
}
|
||
|
||
private static function execute($plan, $approval)
|
||
{
|
||
global $wpdb;
|
||
if ($wpdb->query('START TRANSACTION') === false) {
|
||
throw new RuntimeException('Could not start the repair transaction.');
|
||
}
|
||
try {
|
||
$current = self::preview($plan, true);
|
||
if ($current['counts'] !== $approval['counts'] || $current['fingerprints'] !== $approval['fingerprints']) {
|
||
throw new RuntimeException('Candidate values changed after preview. All changes were rolled back. Generate a fresh preview.');
|
||
}
|
||
if ($wpdb->query('COMMIT') === false) {
|
||
throw new RuntimeException('Could not commit the repair transaction.');
|
||
}
|
||
} catch (Throwable $e) {
|
||
$wpdb->query('ROLLBACK');
|
||
throw $e;
|
||
}
|
||
|
||
// SQL writes bypass WordPress cache invalidation, including alloptions.
|
||
$cache_cleared = wp_cache_flush();
|
||
$rows = array();
|
||
foreach ($current['columns'] as $item) {
|
||
$rows[] = array('table' => $item['table'], 'column' => $item['column'], 'affected' => $item['count']);
|
||
}
|
||
return array('rows' => $rows, 'total' => $current['total'], 'cache_cleared' => $cache_cleared);
|
||
}
|
||
|
||
private static function repair_text($value)
|
||
{
|
||
static $map = null;
|
||
if ($map === null) {
|
||
$map = array();
|
||
// The two common misdecodings: ISO-8859-1 and Windows-1252.
|
||
$windows = array(
|
||
0x80 => '€', 0x82 => '‚', 0x83 => 'ƒ', 0x84 => '„', 0x85 => '…',
|
||
0x86 => '†', 0x87 => '‡', 0x88 => 'ˆ', 0x89 => '‰', 0x8a => 'Š',
|
||
0x8b => '‹', 0x8c => 'Œ', 0x8e => 'Ž', 0x91 => '‘', 0x92 => '’',
|
||
0x93 => '“', 0x94 => '”', 0x95 => '•', 0x96 => '–', 0x97 => '—',
|
||
0x98 => '˜', 0x99 => '™', 0x9a => 'š', 0x9b => '›', 0x9c => 'œ',
|
||
0x9e => 'ž', 0x9f => 'Ÿ',
|
||
);
|
||
// Only these target characters generate replacements. The Windows
|
||
// table above describes misdecoded bytes; it is not a target list.
|
||
$targets = 'áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ'
|
||
. '–—‘’‚“”„…•«»‹›€™©®°' . "\xc2\xa0"; // Nonbreaking space.
|
||
foreach (preg_split('//u', $targets, -1, PREG_SPLIT_NO_EMPTY) as $letter) {
|
||
$latin = '';
|
||
$cp1252 = '';
|
||
foreach (unpack('C*', $letter) as $byte) {
|
||
$encoded = $byte < 128 ? chr($byte) : chr(0xc0 | ($byte >> 6)) . chr(0x80 | ($byte & 0x3f));
|
||
$latin .= $encoded;
|
||
$cp1252 .= isset($windows[$byte]) ? $windows[$byte] : $encoded;
|
||
}
|
||
$map[$latin] = $letter;
|
||
$map[$cp1252] = $letter;
|
||
}
|
||
}
|
||
// Never transcode the whole value: valid Unicode, CSS and JSON stay intact.
|
||
return strtr($value, $map);
|
||
}
|
||
|
||
private static function repair_value($value, $depth = 0)
|
||
{
|
||
if (self::repair_text($value) === $value) {
|
||
return $value;
|
||
}
|
||
if ($depth > 64 || preg_match('//u', $value) !== 1) {
|
||
throw new RuntimeException('Unsupported depth or invalid UTF-8.');
|
||
}
|
||
if (!preg_match('/\A(?:[aObisdCREr]:|N;)/', ltrim($value))) {
|
||
return self::repair_text($value);
|
||
}
|
||
$offset = 0;
|
||
$result = self::repair_serialized($value, $offset, $depth + 1);
|
||
if ($offset !== strlen($value)) {
|
||
throw new RuntimeException('Malformed or unsupported serialized value.');
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
private static function repair_serialized($value, &$offset, $depth, $key = false)
|
||
{
|
||
if ($depth > 64) {
|
||
throw new RuntimeException('Serialized value is too deeply nested.');
|
||
}
|
||
$start = $offset;
|
||
$type = substr($value, $offset, 1);
|
||
if ($key && $type !== 's' && $type !== 'i') {
|
||
throw new RuntimeException('Unsupported serialized key.');
|
||
}
|
||
if ($type === 's' && preg_match('/\Gs:([0-9]+):"/', $value, $match, 0, $offset)) {
|
||
$offset += strlen($match[0]);
|
||
$length = (int) $match[1];
|
||
if ($length > strlen($value) - $offset - 2 || substr($value, $offset + $length, 2) !== '";') {
|
||
throw new RuntimeException('Serialized string length is invalid. Restore this value from backup.');
|
||
}
|
||
$text = substr($value, $offset, $length);
|
||
$offset += $length + 2;
|
||
// Keys are identifiers, and changing them could collide or break settings.
|
||
$fixed = $key ? $text : self::repair_value($text, $depth + 1);
|
||
return $fixed === $text ? substr($value, $start, $offset - $start) : 's:' . strlen($fixed) . ':"' . $fixed . '";';
|
||
}
|
||
if ($type === 'a' && preg_match('/\Ga:([0-9]+):\{/', $value, $match, 0, $offset)) {
|
||
$offset += strlen($match[0]);
|
||
$count = (int) $match[1];
|
||
if ($count > strlen($value) - $offset) {
|
||
throw new RuntimeException('Invalid serialized array length.');
|
||
}
|
||
$result = $match[0];
|
||
for ($i = 0; $i < $count; $i++) {
|
||
$result .= self::repair_serialized($value, $offset, $depth + 1, true);
|
||
$result .= self::repair_serialized($value, $offset, $depth + 1);
|
||
}
|
||
if (substr($value, $offset, 1) !== '}') {
|
||
throw new RuntimeException('Malformed serialized array.');
|
||
}
|
||
$offset++;
|
||
return $result . '}';
|
||
}
|
||
if (preg_match('/\G(?:N;|b:[01];|i:-?[0-9]+;|d:(?:-?INF|NAN|[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?);)/', $value, $match, 0, $offset)) {
|
||
$offset += strlen($match[0]);
|
||
return $match[0];
|
||
}
|
||
// Never instantiate objects or rewrite references/custom serialization.
|
||
throw new RuntimeException('Malformed or unsupported serialized value; left unchanged.');
|
||
}
|
||
|
||
private static function identifier($name)
|
||
{
|
||
if (!preg_match('/\A[A-Za-z0-9_]+\z/', $name)) {
|
||
throw new RuntimeException('Unsafe SQL identifier refused.');
|
||
}
|
||
return '`' . $name . '`';
|
||
}
|
||
|
||
private static function throw_on_db_error()
|
||
{
|
||
global $wpdb;
|
||
if ($wpdb->last_error !== '') {
|
||
throw new RuntimeException('Database error: ' . $wpdb->last_error);
|
||
}
|
||
}
|
||
|
||
private static function make_approval($preview)
|
||
{
|
||
global $wpdb;
|
||
$payload = wp_json_encode(array(
|
||
'user' => get_current_user_id(),
|
||
'database' => DB_NAME,
|
||
'prefix' => $wpdb->prefix,
|
||
'expires' => time() + self::APPROVAL_TTL,
|
||
'version' => self::REPAIR_VERSION,
|
||
'counts' => $preview['counts'],
|
||
'fingerprints' => $preview['fingerprints'],
|
||
));
|
||
$encoded = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
|
||
return $encoded . '.' . hash_hmac('sha256', $encoded, wp_salt('auth'));
|
||
}
|
||
|
||
private static function verify_approval($token, $plan)
|
||
{
|
||
global $wpdb;
|
||
$parts = explode('.', $token, 2);
|
||
if (count($parts) !== 2 || !hash_equals(hash_hmac('sha256', $parts[0], wp_salt('auth')), $parts[1])) {
|
||
throw new RuntimeException('The preview approval is invalid. Generate a fresh preview.');
|
||
}
|
||
|
||
$padding = strlen($parts[0]) % 4;
|
||
$encoded = $parts[0] . ($padding ? str_repeat('=', 4 - $padding) : '');
|
||
$payload = json_decode(base64_decode(strtr($encoded, '-_', '+/'), true), true);
|
||
|
||
if (!is_array($payload)
|
||
|| !isset($payload['version'], $payload['user'], $payload['database'], $payload['prefix'], $payload['expires'], $payload['fingerprints'])
|
||
|| $payload['version'] !== self::REPAIR_VERSION
|
||
|| !is_array($payload['fingerprints'])
|
||
|| (int) $payload['user'] !== get_current_user_id()
|
||
|| (string) $payload['database'] !== DB_NAME
|
||
|| (string) $payload['prefix'] !== $wpdb->prefix
|
||
|| (int) $payload['expires'] < time()
|
||
|| !isset($payload['counts'])
|
||
|| !is_array($payload['counts'])) {
|
||
throw new RuntimeException('The preview approval expired or does not match this site. Generate a fresh preview.');
|
||
}
|
||
|
||
$expected_ids = array();
|
||
foreach ($plan as $table => $definition) {
|
||
foreach ($definition['columns'] as $column) {
|
||
$expected_ids[] = $table . '.' . $column;
|
||
}
|
||
}
|
||
if (array_keys($payload['counts']) !== $expected_ids || array_keys($payload['fingerprints']) !== $expected_ids) {
|
||
throw new RuntimeException('The preview approval does not match the fixed repair plan.');
|
||
}
|
||
|
||
$payload['counts'] = array_map('intval', $payload['counts']);
|
||
return $payload;
|
||
}
|
||
|
||
private static function render_schema($schema)
|
||
{
|
||
echo '<table class="widefat striped" style="max-width:1100px"><thead><tr><th>Core table</th><th>Columns</th><th>Status</th></tr></thead><tbody>';
|
||
foreach ($schema as $table => $status) {
|
||
$state = $status['ready'] ? 'Ready (' . $status['engine'] . ')' : 'Blocked / missing (' . ($status['engine'] ?: 'not found') . ')';
|
||
echo '<tr><td><code>' . esc_html($table) . '</code></td><td><code>' . esc_html(implode(', ', $status['columns'])) . '</code></td><td>' . esc_html($state) . '</td></tr>';
|
||
}
|
||
echo '</tbody></table>';
|
||
}
|
||
|
||
private static function render_preview($preview, $plan)
|
||
{
|
||
echo '<h2>Preview</h2>';
|
||
echo '<p><strong>' . number_format_i18n($preview['total']) . '</strong> differing column values found. Only the samples below will be displayed; the confirmed update covers all counted candidates.</p>';
|
||
echo '<p>Only recognized mojibake sequences for Czech letters, common punctuation, symbols and nonbreaking spaces are replaced. Correct Unicode is preserved, and serialized string lengths are updated. Review the samples to confirm that these replacements match your content.</p>';
|
||
if ($preview['skipped'] > 0) {
|
||
echo '<div class="notice notice-warning"><p><strong>' . number_format_i18n($preview['skipped']) . ' values were skipped.</strong> They contain repair candidates but have malformed or unsupported data (such as serialized objects or references). Already broken settings need recovery from a backup.</p></div>';
|
||
}
|
||
|
||
foreach ($preview['columns'] as $item) {
|
||
echo '<details style="margin:12px 0;padding:10px;background:#fff;border:1px solid #ccd0d4"' . ($item['count'] ? ' open' : '') . '>';
|
||
echo '<summary><code>' . esc_html($item['table'] . '.' . $item['column']) . '</code> — ' . number_format_i18n($item['count']) . ' differing</summary>';
|
||
if ($item['skipped']) {
|
||
echo '<p><strong>Skipped: ' . number_format_i18n($item['skipped']) . '</strong>. Example row IDs: ' . esc_html(implode(', ', $item['skipped_ids'])) . '</p>';
|
||
}
|
||
foreach ($item['samples'] as $sample) {
|
||
echo '<p><strong>Row ' . esc_html((string) $sample['row_id']) . '</strong></p>';
|
||
echo '<table class="widefat"><tr><th style="width:50%">Before</th><th>After</th></tr><tr><td><pre style="white-space:pre-wrap">' . esc_html((string) $sample['before_value']) . '</pre></td><td><pre style="white-space:pre-wrap">' . esc_html((string) $sample['after_value']) . '</pre></td></tr></table>';
|
||
}
|
||
echo '</details>';
|
||
}
|
||
|
||
if ($preview['total'] < 1) {
|
||
echo '<div class="notice notice-success"><p>No supported repair candidates were found.</p></div>';
|
||
return;
|
||
}
|
||
|
||
echo '<h2>Step 2: Run once</h2><form method="post">';
|
||
wp_nonce_field('wpmr_action', 'wpmr_nonce');
|
||
echo '<input type="hidden" name="wpmr_action" value="execute">';
|
||
echo '<input type="hidden" name="approval" value="' . esc_attr(self::make_approval($preview)) . '">';
|
||
echo '<label><input type="checkbox" name="confirm_backup" value="1" required> I have a restorable backup and approve updating every counted candidate above.</label>';
|
||
submit_button('Run repair once', 'primary', 'submit', false, array('onclick' => "return confirm('Run the repair now? This updates all approved candidates.');"));
|
||
echo '</form>';
|
||
}
|
||
|
||
private static function render_result($result)
|
||
{
|
||
echo '<div class="notice notice-success"><p><strong>Repair committed successfully.</strong> ' . number_format_i18n($result['total']) . ' column values updated.</p></div>';
|
||
if (!$result['cache_cleared']) {
|
||
echo '<div class="notice notice-warning"><p>The database repair succeeded, but the WordPress object cache could not be cleared. Clear it through your hosting provider.</p></div>';
|
||
}
|
||
echo '<p>Clear any page cache and regenerate your page builder’s CSS before checking the site.</p>';
|
||
echo '<table class="widefat striped" style="max-width:900px"><thead><tr><th>Table</th><th>Column</th><th>Affected</th></tr></thead><tbody>';
|
||
foreach ($result['rows'] as $row) {
|
||
echo '<tr><td><code>' . esc_html($row['table']) . '</code></td><td><code>' . esc_html($row['column']) . '</code></td><td>' . number_format_i18n($row['affected']) . '</td></tr>';
|
||
}
|
||
echo '</tbody></table><div class="notice notice-error"><p><strong>Now verify the site, then deactivate and delete this plugin.</strong></p></div>';
|
||
}
|
||
}
|
||
|
||
WP_Mojibake_Repair_Temporary::init();
|