diff --git a/README.md b/README.md new file mode 100644 index 0000000..7948832 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Mojibake Repair (Temporary) + +Version 1.1.0 replaces the original whole-column SQL conversion with targeted +replacements for Czech letters misdecoded as ISO-8859-1 or Windows-1252. + +## Why version 1.0.0 could break fonts + +The original conversion also changed `options` and metadata. Theme and builder +settings can contain PHP-serialized strings with embedded **byte lengths**. +Changing the text without updating those lengths makes the settings unreadable; +a theme can then fall back to default typography. Converting entire values could +also damage already-correct Unicode in mixed content. WordPress documents this +serialization risk in its [migration guide](https://developer.wordpress.org/advanced-administration/upgrade/migrating/). + +## Sites already repaired with version 1.0.0 + +1. Deactivate version 1.0.0 and save a copy of the current database. +2. Use a pre-repair backup to compare and recover affected theme/builder settings, + preferably on staging first. A full database restore can discard newer content + and orders, so choose the recovery scope using the site's actual changes. +3. Run the new version's preview against the restored data and review the changes. + The new version prevents this serialization damage; it does not reconstruct + settings already corrupted by the previous conversion. +4. After repair, clear page caches and regenerate builder CSS if the site uses it. + Verify fonts and content on the site, then deactivate and remove the plugin. + +## Behavior and limits + +- Repairs recognized Czech mojibake sequences without transcoding whole values. + Other corruption patterns and JSON `\uXXXX` escapes are outside this repair's + scope. A sequence intentionally shown as a mojibake example will also match, + so review the preview. +- Preserves CSS, JSON escaping, serialized keys and scalar formatting. Repairs + serialized array/string values, including nested serialized strings, and + recalculates their byte lengths without instantiating PHP objects. +- Leaves an entire candidate value unchanged if it contains malformed or + unsupported serialization, objects, references, excessive nesting, or invalid + UTF-8. The preview reports skipped counts and example row IDs. This is not a + general audit of already-corrupted settings: values without a recognized + mojibake sequence are left untouched without further inspection. +- Reads in batches of 200. Execution uses an InnoDB transaction and checks the + exact candidate IDs and values against the signed preview. Changes since + preview or write conflicts cause rollback. Old-version approvals are rejected. +- Clears the WordPress object cache after commit. External page caches and + generated builder styles may still need to be refreshed separately. +- Scans the current site's fixed core-table plan. Large databases may exceed + hosting request limits; batching bounds the rows loaded at once, not the total + request duration or transaction size. + +## Local verification + +Run `php -l wp-mojibake-repair.php` and `php tests/repair.php`. +The regression script requires PDO SQLite and iconv. It checks Czech text, +serialized typography settings, preserved CSS/JSON, skipped unsafe values, +batching, preview approvals, rollback, and cache invalidation. Database-flow +checks use a SQLite adapter; they do not replace verification on WordPress/MySQL. diff --git a/tests/repair.php b/tests/repair.php new file mode 100644 index 0000000..00505b7 --- /dev/null +++ b/tests/repair.php @@ -0,0 +1,163 @@ +setAccessible(true); + } + } + return $methods[$method]->invokeArgs(null, $args); +} +$checks = 0; +function same($expected, $actual, $label) { + $GLOBALS['checks']++; + if ($expected !== $actual) { + throw new RuntimeException($label . '\nExpected: ' . var_export($expected, true) . '\nActual: ' . var_export($actual, true)); + } +} +function refused($callback, $label) { + try { $callback(); } catch (RuntimeException $e) { $GLOBALS['checks']++; return; } + throw new RuntimeException($label . ': expected refusal'); +} +function fix($value) { return call_repair('repair_value', $value); } +function broken($value) { return iconv('ISO-8859-1', 'UTF-8', $value); } + +$czech = 'Příliš žluťoučký kůň úpěl ďábelské ódy. ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ'; +same($czech, fix(broken($czech)), 'Latin-1 mojibake, all Czech letters'); +// These letters exercise printable Windows-1252 mappings including ™, ¾ and œ. +$windows = 'ěňřšťůžČĎĚŇŘŠŤŮŽ'; +same($windows, fix(iconv('Windows-1252', 'UTF-8', $windows)), 'Windows-1252 mojibake'); +$correct = $czech . ' 日本語 😀 € “quoted” — ß ö ñ'; +same($correct, fix($correct), 'Correct multilingual Unicode is byte-for-byte unchanged'); +same($correct . ' Příliš', fix($correct . ' ' . broken('Příliš')), 'Mixed clean and damaged text'); +same($czech, fix(fix(broken($czech))), 'Second pass preserves repaired text'); +$css = 'body{font-family:"Open Sans",sans-serif;font-weight:600;background:url("/a\\b.woff2")}/* '; +same($css . 'Příliš */', fix($css . broken('Příliš') . ' */'), 'CSS bytes stay intact'); +$json = '{"font_family":"Open Sans","url":"https:\/\/example.test\/font.woff2","unicode":"\\u010d","text":"' . broken('Příliš') . '"}'; +same(str_replace(broken('Příliš'), 'Příliš', $json), fix($json), 'JSON escaping stays intact'); +$settings = array('font_family' => 'Open Sans', 'font_weight' => 600, 'label' => broken('Příliš'), 'nested' => array('css' => $css . '*/', 'json' => $json), 'enabled' => true, 'empty' => null, 'scale' => 1.25); +$expected = $settings; +$expected['label'] = 'Příliš'; +$expected['nested']['json'] = fix($json); +same(false, @unserialize(call_repair('repair_text', serialize($settings))), 'Replacing serialized text without fixing lengths breaks settings'); +$repaired = fix(serialize($settings)); +same($expected, unserialize($repaired), 'Serialized font settings remain readable with correct values'); +same(serialize($expected), $repaired, 'Serialized byte lengths are recalculated'); +same($repaired, fix($repaired), 'Serialized settings survive repeat repair'); +$nested = serialize(array('inner' => serialize(array('label' => broken('kůň'))))); +same(array('label' => 'kůň'), unserialize(unserialize(fix($nested))['inner']), 'Nested serialization lengths'); +same(serialize(array(broken('č') => 'č')), fix(serialize(array(broken('č') => broken('č')))), 'Serialized keys stay unchanged'); +$safe = 'a:2:{s:1:"x";d:1.2500;s:1:"y";' . serialize(broken('č')) . '}'; +same(str_replace(serialize(broken('č')), serialize('č'), $safe), fix($safe), 'Scalar formatting stays intact'); +$malformed = 'a:1:{s:5:"label";s:999:"' . broken('Příliš') . '";}'; +refused(function () use ($malformed) { fix($malformed); }, 'Already broken serialization'); +refused(function () { fix(serialize(broken('č')) . 'trailing'); }, 'Trailing serialized data'); +refused(function () { fix(' ' . serialize(broken('č'))); }, 'Noncanonical serialized wrapper'); +refused(function () { fix(broken('č') . "\xff"); }, 'Invalid UTF-8 candidate'); +class RepairTestObject { + public $label; + public function __wakeup() { throw new LogicException('Must never instantiate objects'); } +} +$object = new RepairTestObject(); +$object->label = broken('č'); +refused(function () use ($object) { fix(serialize($object)); }, 'Serialized objects skipped without hooks'); +refused(function () use ($object) { fix(serialize(array('label' => broken('č'), 'object' => $object))); }, 'Whole row skipped for nested objects'); +$reference = broken('č'); +$references = array(&$reference, &$reference); +refused(function () use ($references) { fix(serialize($references)); }, 'References skipped'); +$deep = broken('č'); +for ($i = 0; $i < 70; $i++) { $deep = array($deep); } +refused(function () use ($deep) { fix(serialize($deep)); }, 'Depth bounded'); + +// Exercise the scan, exact-value approvals and transaction flow with SQLite. +// This adapter is not a substitute for verification on a WordPress/MySQL site. +class RepairTestDB { + public $prefix = 'wp_'; + public $last_error = ''; + public $db; + public $fail_update = false; + public $conflict = false; + public function __construct() { + $this->db = new PDO('sqlite::memory:'); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->db->exec('CREATE TABLE wp_options (option_id INTEGER PRIMARY KEY, option_value TEXT)'); + } + public function prepare($sql, ...$args) { + return preg_replace_callback('/%[sd]/', function ($match) use (&$args) { + $value = array_shift($args); + return $match[0] === '%d' ? (string) (int) $value : $this->db->quote($value); + }, $sql); + } + public function get_results($sql, $format) { return $this->db->query($sql)->fetchAll(PDO::FETCH_ASSOC); } + public function query($sql) { + if ($sql === 'START TRANSACTION') { return $this->db->beginTransaction() ? 0 : false; } + if ($sql === 'COMMIT') { return $this->db->commit() ? 0 : false; } + if ($sql === 'ROLLBACK') { return $this->db->rollBack() ? 0 : false; } + if ($this->fail_update) { $this->last_error = 'simulated write failure'; return false; } + if ($this->conflict) { return 0; } + // SQLite text equality already compares bytes with its default collation. + return $this->db->exec(str_replace('BINARY ', '', $sql)); + } + public function put($id, $text) { + $this->db->prepare('INSERT OR REPLACE INTO wp_options VALUES (?, ?)')->execute(array($id, $text)); + } + public function all() { return $this->db->query('SELECT * FROM wp_options ORDER BY option_id')->fetchAll(PDO::FETCH_ASSOC); } +} +$wpdb = new RepairTestDB(); +$GLOBALS['flushes'] = 0; +$plan = array('wp_options' => array('key' => 'option_id', 'columns' => array('option_value'))); +for ($i = 0; $i < 405; $i++) { $wpdb->put($i, serialize($settings)); } +$wpdb->put(500, $malformed); +$wpdb->put(501, $correct); +$wpdb->put(502, null); +$before = $wpdb->all(); +$preview = call_repair('preview', $plan); +same($before, $wpdb->all(), 'Preview is read-only'); +same(405, $preview['total'], 'Batches include all rows including ID zero'); +same(1, $preview['skipped'], 'Malformed row reported separately'); +same(array('500'), $preview['columns'][0]['skipped_ids'], 'Skipped ID shown'); +same(5, count($preview['columns'][0]['samples']), 'Sample limit'); +$token = call_repair('make_approval', $preview); +$approval = call_repair('verify_approval', $token, $plan); +refused(function () use ($token, $plan) { call_repair('verify_approval', 'x' . $token, $plan); }, 'Tampered approval'); +$old = array('user' => 7, 'database' => DB_NAME, 'prefix' => 'wp_', 'expires' => time() + 600, 'counts' => $preview['counts']); +$encoded = rtrim(strtr(base64_encode(json_encode($old)), '+/', '-_'), '='); +$old_token = $encoded . '.' . hash_hmac('sha256', $encoded, wp_salt('auth')); +refused(function () use ($old_token, $plan) { call_repair('verify_approval', $old_token, $plan); }, 'Old blanket-conversion approval rejected'); +$wpdb->put(0, broken('Jiný text')); +$changed = $wpdb->all(); +refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Same count but changed values rejected'); +same($changed, $wpdb->all(), 'Every write rolled back for stale preview'); +same(0, $GLOBALS['flushes'], 'No cache flush after rollback'); +$wpdb->put(0, serialize($settings)); +$wpdb->conflict = true; +refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Concurrent write conflict'); +same($before, $wpdb->all(), 'Conflict leaves database unchanged'); +$wpdb->conflict = false; +$wpdb->fail_update = true; +refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Database error rolls back'); +same($before, $wpdb->all(), 'Failed update leaves database unchanged'); +$wpdb->fail_update = false; +$wpdb->last_error = ''; +$result = call_repair('execute', $plan, $approval); +same(405, $result['total'], 'Approved updates committed'); +same(1, $GLOBALS['flushes'], 'Object cache cleared after commit'); +$after = $wpdb->all(); +same($expected, unserialize($after[0]['option_value']), 'Database font settings remain readable'); +same($malformed, $after[405]['option_value'], 'Malformed row untouched'); +same($correct, $after[406]['option_value'], 'Clean row untouched'); +same(null, $after[407]['option_value'], 'NULL untouched'); +same(0, call_repair('preview', $plan)['total'], 'Second preview has no repair candidates'); +echo 'Passed ' . $checks . " checks.\n"; diff --git a/wp-mojibake-repair.php b/wp-mojibake-repair.php index 70fe30d..84853ca 100644 --- a/wp-mojibake-repair.php +++ b/wp-mojibake-repair.php @@ -1,8 +1,8 @@ getMessage(); @@ -164,116 +166,217 @@ final class WP_Mojibake_Repair_Temporary } } - private static function preview($plan) + private static function preview($plan, $write = false) { global $wpdb; - $preview = array('columns' => array(), 'counts' => array(), 'total' => 0, 'serialized' => 0); + $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); - $converted = self::conversion($column); - $diff = self::difference($column); $id = $table . '.' . $column; - - $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table_sql} WHERE {$diff}"); - self::throw_on_db_error(); - - $serialized = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM {$table_sql} WHERE {$diff} AND (LEFT({$column_sql},2) IN ('a:','O:','s:','i:','d:','b:','C:') OR {$column_sql}='N;')" - ); - self::throw_on_db_error(); - + $count = 0; + $skipped = 0; $samples = array(); - if ($count > 0) { - $limit = self::SAMPLE_LIMIT; - $length = self::SAMPLE_LENGTH; - $samples = $wpdb->get_results( - "SELECT {$key_sql} AS row_id, LEFT({$column_sql},{$length}) AS before_value, LEFT({$converted},{$length}) AS after_value " . - "FROM {$table_sql} WHERE {$diff} ORDER BY {$key_sql} ASC LIMIT {$limit}", + $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, - 'serialized' => $serialized, - 'samples' => $samples, + '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['serialized'] += $serialized; + $preview['skipped'] += $skipped; } } - return $preview; } - private static function execute($plan, $approved_counts) + 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; - - $current = self::preview($plan); - if ($current['counts'] !== $approved_counts) { - throw new RuntimeException('The candidate counts changed after preview. Generate a fresh preview.'); - } - if ($wpdb->query('START TRANSACTION') === false) { - self::throw_on_db_error(); + throw new RuntimeException('Could not start the repair transaction.'); } - - $rows = array(); - $total = 0; try { - foreach ($plan as $table => $definition) { - foreach ($definition['columns'] as $column) { - $id = $table . '.' . $column; - $expected = isset($approved_counts[$id]) ? (int) $approved_counts[$id] : -1; - $table_sql = self::identifier($table); - $column_sql = self::identifier($column); - $converted = self::conversion($column); - $diff = self::difference($column); - - $affected = $wpdb->query("UPDATE {$table_sql} SET {$column_sql}={$converted} WHERE {$diff}"); - if ($affected === false) { - self::throw_on_db_error(); - } - if ((int) $affected !== $expected) { - throw new RuntimeException('Affected-row count changed while updating ' . $id . '.'); - } - - $rows[] = array('table' => $table, 'column' => $column, 'affected' => (int) $affected); - $total += (int) $affected; - } + $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) { - self::throw_on_db_error(); + throw new RuntimeException('Could not commit the repair transaction.'); } } catch (Throwable $e) { $wpdb->query('ROLLBACK'); throw $e; } - return array('rows' => $rows, 'total' => $total); + // 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 conversion($column) + private static function repair_text($value) { - $column_sql = self::identifier($column); - return "CONVERT(BINARY CONVERT({$column_sql} USING latin1) USING utf8mb4)"; + 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 => 'Ÿ', + ); + foreach (preg_split('//u', 'áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ', -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 difference($column) + private static function repair_value($value, $depth = 0) { - $column_sql = self::identifier($column); - $converted = self::conversion($column); - return "NOT (BINARY {$column_sql} <=> BINARY {$converted})"; + 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) @@ -292,7 +395,7 @@ final class WP_Mojibake_Repair_Temporary } } - private static function make_approval($counts) + private static function make_approval($preview) { global $wpdb; $payload = wp_json_encode(array( @@ -300,7 +403,9 @@ final class WP_Mojibake_Repair_Temporary 'database' => DB_NAME, 'prefix' => $wpdb->prefix, 'expires' => time() + self::APPROVAL_TTL, - 'counts' => $counts, + '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')); @@ -319,6 +424,9 @@ final class WP_Mojibake_Repair_Temporary $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 @@ -334,7 +442,7 @@ final class WP_Mojibake_Repair_Temporary $expected_ids[] = $table . '.' . $column; } } - if (array_keys($payload['counts']) !== $expected_ids) { + 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.'); } @@ -356,16 +464,16 @@ final class WP_Mojibake_Repair_Temporary { echo '
' . number_format_i18n($preview['total']) . ' differing column values found. Only the samples below will be displayed; the confirmed update covers all counted candidates.
'; - echo 'Important: “different” does not prove mojibake. The conversion is not safe to run twice. Correct non-Latin-1 text can also change.
' . number_format_i18n($preview['serialized']) . ' candidate values look PHP-serialized. SQL conversion can invalidate embedded string lengths. Review these samples carefully.
Only recognized Czech mojibake sequences are replaced. Correct Unicode is preserved, and serialized string lengths are updated. Review the samples to confirm that these replacements match your content.
'; + if ($preview['skipped'] > 0) { + echo '' . number_format_i18n($preview['skipped']) . ' values were skipped. They contain repair candidates but have malformed or unsupported data (such as serialized objects or references). Already broken settings need recovery from a backup.
' . esc_html($item['table'] . '.' . $item['column']) . ' — ' . number_format_i18n($item['count']) . ' differingSerialized-looking: ' . number_format_i18n($item['serialized']) . '
'; + if ($item['skipped']) { + echo 'Skipped: ' . number_format_i18n($item['skipped']) . '. Example row IDs: ' . esc_html(implode(', ', $item['skipped_ids'])) . '
'; } foreach ($item['samples'] as $sample) { echo 'Row ' . esc_html((string) $sample['row_id']) . '
'; @@ -375,22 +483,26 @@ final class WP_Mojibake_Repair_Temporary } if ($preview['total'] < 1) { - echo 'No update is needed.
No supported repair candidates were found.
Repair committed successfully. ' . number_format_i18n($result['total']) . ' column values updated.
The database repair succeeded, but the WordPress object cache could not be cleared. Clear it through your hosting provider.
Clear any page cache and regenerate your page builder’s CSS before checking the site.
'; echo '| Table | Column | Affected |
|---|---|---|
' . esc_html($row['table']) . ' | ' . esc_html($row['column']) . ' | ' . number_format_i18n($row['affected']) . ' |