PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-seo-restore.php

class-metasync-seo-restore.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-seo-restore.php

1,295 lines 41.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Putting third-party SEO values back the way MetaSync found them.
4 *
5 * `Metasync_Seo_Backup` saved an original for every third-party field it
6 * overwrote. Until now nothing read those rows back — `read_backup()` had no
7 * production caller at all — so this class is the first consumer of that data,
8 * and every mistake it makes is a customer value deleted or blanked.
9 *
10 * A backup row's key carries the whole instruction: which of six unrelated
11 * storage mechanisms the original came out of, and for two of them which table
12 * row or option entry it belongs to. Restore therefore begins by parsing that
13 * key, and the parse has to be exact — a field routed to the wrong mechanism
14 * writes into storage it does not own.
15 *
16 * Includes background batch execution and queue management modeled on
17 * `Metasync_Media_Batch_Optimizer`.
18 *
19 * @package MetaSync
20 * @subpackage MetaSync/includes
21 */
22
23 if (!defined('ABSPATH')) {
24 exit;
25 }
26
27 class Metasync_Seo_Restore {
28
29 /**
30 * Backup rows that are bookkeeping, not values.
31 *
32 * These record whether a third-party table row existed before MetaSync
33 * inserted it. They are never written back into anything — writing
34 * `aioseo_row_existed` as if it were a column would put "1" into a real
35 * AIOSEO field. They are read to decide whether to delete a row or revert
36 * its columns, and then dropped.
37 *
38 * Matched exactly, and matched *first*, because both of them also carry a
39 * mechanism prefix: `aioseo_row_existed` looks like the `aioseo_{column}`
40 * pattern and `yoast_indexable_row_existed` looks like
41 * `yoast_indexable_{column}`. Prefix order alone would misroute both.
42 *
43 * @var string[]
44 */
45 const MARKER_FIELDS = [
46 'aioseo_row_existed',
47 'yoast_indexable_row_existed',
48 ];
49
50 /**
51 * AIOSEO's pre-table term fields, which are ordinary term meta.
52 *
53 * Older AIOSEO stored term SEO in term meta under these two keys. They
54 * collide with the `aioseo_{column}` table pattern on everything but the
55 * leading underscore, so they are matched exactly and ahead of it.
56 *
57 * @var string[]
58 */
59 const LEGACY_AIOSEO_TERM_META = [
60 '_aioseo_title',
61 '_aioseo_description',
62 ];
63
64 // Batch driver options & constants (modeled on Metasync_Media_Batch_Optimizer)
65 public const QUEUE_OPTION = 'metasync_seo_restore_queue';
66 public const PROGRESS_OPTION = 'metasync_seo_restore_progress';
67 public const LOCK_OPTION = 'metasync_seo_restore_lock';
68 public const CRON_HOOK = 'metasync_seo_restore_cron';
69
70 private const LOCK_STALE_AFTER = 120; // seconds before lock can be stolen
71 private const DEFAULT_BATCH_SIZE = 25; // items per tick
72 private const CRON_TIME_LIMIT = 30; // seconds per cron tick
73 private const TIME_SAFETY_MARGIN = 5; // safety buffer before PHP timeout
74 private const QUERY_PAGE_SIZE = 1000;
75
76 /**
77 * Turn off the third-party consent toggle at the start of a restore run.
78 *
79 * Running this before any object is touched prevents any concurrent OTTO sync
80 * from immediately re-overwriting third-party storage during the restore.
81 *
82 * @return bool True if disabled or already off.
83 */
84 public static function disable_consent() {
85 if (!class_exists('Metasync')) {
86 return false;
87 }
88
89 $options = Metasync::get_option();
90 if (!is_array($options)) {
91 $options = [];
92 }
93
94 if (!isset($options['general']) || !is_array($options['general'])) {
95 $options['general'] = [];
96 }
97
98 // Already off: update_option() returns false for an unchanged value,
99 // which must not be mistaken for a failure to disable.
100 if (empty($options['general'][Metasync_Seo_Backup::CONSENT_OPTION_KEY])) {
101 return true;
102 }
103
104 $options['general'][Metasync_Seo_Backup::CONSENT_OPTION_KEY] = false;
105 $saved = Metasync::set_option($options);
106
107 // False is ambiguous (unchanged vs failed); the stored state decides.
108 if ($saved === false) {
109 $stored = Metasync::get_option();
110 return empty($stored['general'][Metasync_Seo_Backup::CONSENT_OPTION_KEY]);
111 }
112
113 return true;
114 }
115
116 /**
117 * Turn the third-party consent toggle back on.
118 *
119 * Only used to undo this class's own disable_consent() when a run turns out
120 * to have nothing to restore: an empty run must not change the user's
121 * setting, so the pre-run value is put back exactly as it was found.
122 *
123 * @return bool True if enabled or already on.
124 */
125 public static function enable_consent() {
126 if (!class_exists('Metasync')) {
127 return false;
128 }
129
130 $options = Metasync::get_option();
131 if (!is_array($options)) {
132 $options = [];
133 }
134
135 if (!isset($options['general']) || !is_array($options['general'])) {
136 $options['general'] = [];
137 }
138
139 if (!empty($options['general'][Metasync_Seo_Backup::CONSENT_OPTION_KEY])) {
140 return true;
141 }
142
143 $options['general'][Metasync_Seo_Backup::CONSENT_OPTION_KEY] = true;
144 $saved = Metasync::set_option($options);
145
146 // False is ambiguous (unchanged vs failed); the stored state decides.
147 if ($saved === false) {
148 $stored = Metasync::get_option();
149 return !empty($stored['general'][Metasync_Seo_Backup::CONSENT_OPTION_KEY]);
150 }
151
152 return true;
153 }
154
155 /**
156 * Work out what storage a backup row's field name refers to.
157 *
158 * The field name is the backup key with `BACKUP_META_PREFIX` already
159 * stripped. Six mechanisms are in play and they are not distinguished by
160 * anything as tidy as a single prefix, so the tests below run in a fixed
161 * order: exact matches first, then the prefixes that would otherwise
162 * swallow them.
163 *
164 * Anything unrecognised is reported as such rather than guessed at. A field
165 * this code does not understand is a field written by a newer build than
166 * this one, and the safe response is to leave it alone — a wrong guess
167 * writes into another plugin's storage.
168 *
169 * @param string $field Field name from a backup row.
170 * @return array{kind:string,key?:string,column?:string,taxonomy?:string|null,field?:string}
171 * `kind` is one of: marker, post_meta, term_meta, aioseo_column,
172 * yoast_indexable_column, yoast_tax, unrecognised.
173 */
174 public static function classify_field($field) {
175 if ($field === '') {
176 return ['kind' => 'unrecognised', 'taxonomy' => null, 'field' => ''];
177 }
178
179 // Bookkeeping before values, or both markers get routed as columns.
180 if (in_array($field, self::MARKER_FIELDS, true)) {
181 return ['kind' => 'marker', 'field' => $field];
182 }
183
184 // Term meta before the AIOSEO table prefix, which it otherwise matches.
185 if (in_array($field, self::LEGACY_AIOSEO_TERM_META, true)) {
186 return ['kind' => 'term_meta', 'key' => $field];
187 }
188
189 if (strpos($field, 'yoast_tax_') === 0) {
190 return self::classify_yoast_tax_field($field);
191 }
192
193 if (strpos($field, 'yoast_indexable_') === 0) {
194 return [
195 'kind' => 'yoast_indexable_column',
196 'column' => substr($field, strlen('yoast_indexable_')),
197 ];
198 }
199
200 if (strpos($field, 'aioseo_') === 0) {
201 return [
202 'kind' => 'aioseo_column',
203 'column' => substr($field, strlen('aioseo_')),
204 ];
205 }
206
207 // Rank Math and Yoast post/term fields are stored under their own names,
208 // so the field name *is* the meta key.
209 if (strpos($field, 'rank_math_') === 0 || strpos($field, '_yoast_wpseo_') === 0) {
210 return ['kind' => 'post_meta', 'key' => $field];
211 }
212
213 return ['kind' => 'unrecognised'];
214 }
215
216 /**
217 * Split `yoast_tax_{taxonomy}_{wpseo_field}` into its two halves.
218 *
219 * Both halves can contain underscores — `product_cat` is a real taxonomy
220 * slug and every Yoast field name has an underscore of its own — so
221 * `explode('_')` cannot do this. The split point is the *last* occurrence of `_wpseo`, which is
222 * sound for a reason worth stating: every field in
223 * `WPSEO_Taxonomy_Meta::$defaults_per_term` begins with `wpseo` and none
224 * contains `_wpseo` after that first character. So the last `_wpseo` in the
225 * key can only ever be the one that starts the field name, even when the
226 * taxonomy slug happens to contain `_wpseo` itself.
227 *
228 * Deriving the boundary this way rather than matching against a copy of
229 * Yoast's field list matters twice over: the list lives in a class that is
230 * gone when Yoast is deactivated, and `enrich_defaults()` lets add-ons add
231 * fields that no hardcoded list would contain.
232 *
233 * A key with no `_wpseo` in its remainder is one of the legacy,
234 * taxonomy-less rows earlier builds wrote. Those name a field but not the
235 * taxonomy it belongs to, so `taxonomy` comes back null and the caller has
236 * to establish it from the term itself.
237 *
238 * @param string $field Field name beginning `yoast_tax_`.
239 * @return array<string, mixed>
240 */
241 private static function classify_yoast_tax_field($field) {
242 $remainder = substr($field, strlen('yoast_tax_'));
243
244 if ($remainder === '') {
245 return ['kind' => 'unrecognised', 'taxonomy' => null, 'field' => $remainder];
246 }
247
248 $boundary = strrpos($remainder, '_wpseo');
249
250 // No taxonomy segment at all: the legacy `yoast_tax_{field}` shape.
251 if ($boundary === false) {
252 if (strpos($remainder, 'wpseo_') !== 0 || $remainder === 'wpseo_') {
253 return ['kind' => 'unrecognised', 'taxonomy' => null, 'field' => $remainder];
254 }
255
256 return [
257 'kind' => 'yoast_tax',
258 'taxonomy' => null,
259 'field' => $remainder,
260 ];
261 }
262
263 $taxonomy = substr($remainder, 0, $boundary);
264 $yoast_field = substr($remainder, $boundary + 1);
265
266 // Both halves have to be real. An empty taxonomy names no option entry,
267 // and a field of exactly `wpseo` names no Yoast field -- every key in
268 // `$defaults_per_term` is `wpseo_` followed by something. Checking the
269 // convention rather than a copy of the list keeps this working when
270 // Yoast is deactivated and when an add-on has added a field of its own.
271 if ($taxonomy === '' || strpos($yoast_field, 'wpseo_') !== 0 || $yoast_field === 'wpseo_') {
272 return ['kind' => 'unrecognised', 'taxonomy' => $taxonomy, 'field' => $yoast_field];
273 }
274
275 return [
276 'kind' => 'yoast_tax',
277 'taxonomy' => $taxonomy,
278 'field' => $yoast_field,
279 ];
280 }
281
282 /**
283 * Restore all backed up third-party SEO values for one post or term.
284 *
285 * Idempotent: returns success and does nothing if no backup keys exist.
286 * Deletes the backup meta keys after a successful restoration.
287 *
288 * @param string $object_type 'post' or 'term'.
289 * @param int $object_id Object ID.
290 * @return array{success:bool,restored_count:int,deleted_keys:int,errors:array}
291 */
292 public static function restore_object($object_type, $object_id) {
293 $result = [
294 'success' => true,
295 'restored_count' => 0,
296 'deleted_keys' => 0,
297 'errors' => [],
298 ];
299
300 if (!in_array($object_type, ['post', 'term'], true) || (int) $object_id <= 0) {
301 $result['success'] = false;
302 $result['errors'][] = 'Invalid object type or ID';
303 return $result;
304 }
305
306 $object_id = (int) $object_id;
307 $backup_keys = self::get_backup_meta_keys($object_type, $object_id);
308
309 if (empty($backup_keys)) {
310 return $result;
311 }
312
313 $prefix_len = strlen(Metasync_Seo_Backup::BACKUP_META_PREFIX);
314 $markers = [];
315 $fields = [];
316
317 foreach ($backup_keys as $meta_key) {
318 $field_name = substr($meta_key, $prefix_len);
319 $classified = self::classify_field($field_name);
320
321 $backup = Metasync_Seo_Backup::read_backup($object_type, $object_id, $field_name);
322 if (!$backup['exists']) {
323 continue;
324 }
325
326 if ($classified['kind'] === 'marker') {
327 $markers[$classified['field']] = $backup['value'];
328 } else {
329 $fields[] = [
330 'field_name' => $field_name,
331 'classified' => $classified,
332 'value' => $backup['value'],
333 ];
334 }
335 }
336
337 // Group fields by storage mechanism for atomic / unified execution.
338 // Anything that lands in no bucket is recorded in $unroutable and fails
339 // the whole object: an unhandled field is one written by a newer build
340 // than this one, and deleting its backup (the all-or-nothing gate runs
341 // only when success stays true) would destroy the only copy of the
342 // customer's original while leaving the live value in place.
343 $post_or_term_meta = [];
344 $legacy_term_meta = [];
345 $aioseo_cols = [];
346 $yoast_cols = [];
347 $yoast_tax_entries = [];
348 $unroutable = [];
349
350 foreach ($fields as $item) {
351 $kind = $item['classified']['kind'];
352 $val = $item['value'];
353
354 if ($kind === 'post_meta') {
355 $post_or_term_meta[$item['classified']['key']] = $val;
356 } elseif ($kind === 'term_meta') {
357 $legacy_term_meta[$item['classified']['key']] = $val;
358 } elseif ($kind === 'aioseo_column') {
359 $aioseo_cols[$item['classified']['column']] = $val;
360 } elseif ($kind === 'yoast_indexable_column') {
361 // Yoast indexables exist only for posts; a term backup row
362 // claiming one is contamination, not a restore instruction.
363 if ($object_type === 'post') {
364 $yoast_cols[$item['classified']['column']] = $val;
365 } else {
366 $unroutable[] = $item['field_name'];
367 }
368 } elseif ($kind === 'yoast_tax') {
369 // Taxonomy option rows exist only for terms; on a post this is
370 // contamination, and on a term with no resolvable taxonomy the
371 // option cannot be addressed safely.
372 if ($object_type !== 'term') {
373 $unroutable[] = $item['field_name'];
374 continue;
375 }
376 $tax = $item['classified']['taxonomy'];
377 if ($tax === null) {
378 $term_obj = get_term($object_id);
379 if ($term_obj && !is_wp_error($term_obj)) {
380 $tax = $term_obj->taxonomy;
381 }
382 }
383 if ($tax !== null) {
384 $yoast_tax_entries[$tax][$item['classified']['field']] = $val;
385 } else {
386 $unroutable[] = $item['field_name'];
387 }
388 } else {
389 // classify_field() refused to guess; so must the restore.
390 $unroutable[] = $item['field_name'];
391 }
392 }
393
394 if (!empty($unroutable)) {
395 $result['success'] = false;
396 $result['errors'][] = 'Unroutable backup fields (left untouched, backups kept): ' . implode(', ', $unroutable);
397 }
398
399 // 1. Table: AIOSEO
400 if (!empty($aioseo_cols) || isset($markers['aioseo_row_existed'])) {
401 $aioseo_marker = $markers['aioseo_row_existed'] ?? null;
402 $aioseo_ok = self::restore_aioseo_table($object_type, $object_id, $aioseo_cols, $aioseo_marker);
403 if (!$aioseo_ok) {
404 $result['success'] = false;
405 $result['errors'][] = 'Failed restoring AIOSEO table data';
406 } else {
407 $result['restored_count'] += count($aioseo_cols) + (isset($markers['aioseo_row_existed']) ? 1 : 0);
408 }
409 }
410
411 // 2. Table: Yoast Indexable (post only)
412 if ($object_type === 'post' && (!empty($yoast_cols) || isset($markers['yoast_indexable_row_existed']))) {
413 $yoast_marker = $markers['yoast_indexable_row_existed'] ?? null;
414 $yoast_ok = self::restore_yoast_indexable_table($object_id, $yoast_cols, $yoast_marker);
415 if (!$yoast_ok) {
416 $result['success'] = false;
417 $result['errors'][] = 'Failed restoring Yoast indexable table data';
418 } else {
419 $result['restored_count'] += count($yoast_cols) + (isset($markers['yoast_indexable_row_existed']) ? 1 : 0);
420 }
421 }
422
423 // 3. Post/Term meta (Rank Math & _yoast_wpseo_*)
424 foreach ($post_or_term_meta as $key => $val) {
425 $write_ok = self::restore_meta_field($object_type, $object_id, $key, $val);
426 if ($write_ok) {
427 $result['restored_count']++;
428 } else {
429 $result['success'] = false;
430 $result['errors'][] = "Failed restoring meta field: $key";
431 }
432 }
433
434 // 4. Legacy AIOSEO term meta (_aioseo_title, _aioseo_description)
435 foreach ($legacy_term_meta as $key => $val) {
436 $write_ok = self::restore_meta_field('term', $object_id, $key, $val);
437 if ($write_ok) {
438 $result['restored_count']++;
439 } else {
440 $result['success'] = false;
441 $result['errors'][] = "Failed restoring legacy term meta field: $key";
442 }
443 }
444
445 // 5. Yoast taxonomy meta option
446 if ($object_type === 'term' && !empty($yoast_tax_entries)) {
447 foreach ($yoast_tax_entries as $tax => $fields_map) {
448 $tax_ok = self::restore_yoast_taxonomy_meta($object_id, $tax, $fields_map);
449 if ($tax_ok) {
450 $result['restored_count'] += count($fields_map);
451 } else {
452 $result['success'] = false;
453 $result['errors'][] = "Failed restoring Yoast taxonomy meta for taxonomy: $tax";
454 }
455 }
456 }
457
458 // Delete backup keys only if all operations succeeded
459 if ($result['success']) {
460 $deleted = self::delete_backup_meta_keys($object_type, $object_id, $backup_keys);
461 $result['deleted_keys'] = $deleted;
462 }
463
464 return $result;
465 }
466
467 /**
468 * Fetch all raw backup meta keys present on an object.
469 *
470 * @param string $object_type 'post' or 'term'.
471 * @param int $object_id Object ID.
472 * @return string[]
473 */
474 private static function get_backup_meta_keys($object_type, $object_id) {
475 global $wpdb;
476 if (!isset($wpdb)) {
477 return [];
478 }
479
480 $prefix_like = $wpdb->esc_like(Metasync_Seo_Backup::BACKUP_META_PREFIX) . '%';
481 if ($object_type === 'post') {
482 return $wpdb->get_col($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
483 "SELECT meta_key FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE %s",
484 $object_id,
485 $prefix_like
486 )) ?: [];
487 }
488
489 return $wpdb->get_col($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
490 "SELECT meta_key FROM {$wpdb->termmeta} WHERE term_id = %d AND meta_key LIKE %s",
491 $object_id,
492 $prefix_like
493 )) ?: [];
494 }
495
496 /**
497 * Remove backup keys from post or term meta after restoration.
498 *
499 * @param string $object_type 'post' or 'term'.
500 * @param int $object_id Object ID.
501 * @param string[] $keys Array of meta keys to delete.
502 * @return int Number of deleted keys.
503 */
504 private static function delete_backup_meta_keys($object_type, $object_id, array $keys) {
505 $deleted = 0;
506 foreach ($keys as $k) {
507 if ($object_type === 'post') {
508 if (delete_post_meta($object_id, $k)) {
509 $deleted++;
510 }
511 } else {
512 if (delete_term_meta($object_id, $k)) {
513 $deleted++;
514 }
515 }
516 }
517 return $deleted;
518 }
519
520 /**
521 * Restore a single standard post or term meta field.
522 *
523 * @param string $object_type 'post' or 'term'.
524 * @param int $object_id Object ID.
525 * @param string $key Meta key.
526 * @param mixed $value Backup value (null means delete).
527 * @return bool
528 */
529 private static function restore_meta_field($object_type, $object_id, $key, $value) {
530 if ($value === null) {
531 // A null original means "was absent". delete_*_meta() returns false
532 // when nothing matched, which is the goal state already reached —
533 // treat that as success or already-correct objects wedge as failed
534 // on every retry.
535 if ($object_type === 'post') {
536 return !metadata_exists('post', $object_id, $key) || delete_post_meta($object_id, $key);
537 }
538 return !metadata_exists('term', $object_id, $key) || delete_term_meta($object_id, $key);
539 }
540
541 $slashed = wp_slash($value);
542
543 if ($object_type === 'post') {
544 // update_post_meta() returns false both for real failures and for
545 // identical-value no-ops, so the only trustworthy check is the
546 // stored value itself.
547 update_post_meta($object_id, $key, $slashed);
548 $stored = get_post_meta($object_id, $key, true);
549 return ($stored == $value);
550 }
551
552 update_term_meta($object_id, $key, $slashed);
553 $stored = get_term_meta($object_id, $key, true);
554 return ($stored == $value);
555 }
556
557 /**
558 * Restore AIOSEO custom table data for post or term.
559 *
560 * @param string $object_type 'post' or 'term'.
561 * @param int $object_id Object ID.
562 * @param array $columns Backed up column values.
563 * @param string|null $marker '0' (created by MetaSync), '1' (pre-existing), or null (absent marker).
564 * @return bool
565 */
566 private static function restore_aioseo_table($object_type, $object_id, array $columns, $marker) {
567 global $wpdb;
568 if (!isset($wpdb)) {
569 return false;
570 }
571
572 $is_post = ($object_type === 'post');
573
574 // If marker is '0', the row was created by MetaSync -> delete row.
575 if ($marker === '0') {
576 $deleted = $is_post
577 ? $wpdb->delete($wpdb->prefix . 'aioseo_posts', array('post_id' => $object_id)) // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
578 : $wpdb->delete($wpdb->prefix . 'aioseo_terms', array('term_id' => $object_id));
579 return ($deleted !== false);
580 }
581
582 // Marker is '1' or absent (fail-closed to per-column update)
583 if (empty($columns)) {
584 return true;
585 }
586
587 $updated = $is_post
588 ? $wpdb->update($wpdb->prefix . 'aioseo_posts', $columns, array('post_id' => $object_id)) // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
589 : $wpdb->update($wpdb->prefix . 'aioseo_terms', $columns, array('term_id' => $object_id));
590
591 if ($updated === false) {
592 return false;
593 }
594
595 // 0 affected rows is ambiguous: identical values (fine) or the row is
596 // gone (the original values now have nowhere to live — fail so the
597 // backup keys survive). A row-existence probe settles it.
598 if ($updated === 0) {
599 $exists = $is_post
600 ? $wpdb->get_var($wpdb->prepare("SELECT post_id FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d", $object_id)) // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
601 : $wpdb->get_var($wpdb->prepare("SELECT term_id FROM {$wpdb->prefix}aioseo_terms WHERE term_id = %d", $object_id));
602 return ($exists !== null);
603 }
604
605 return true;
606 }
607
608 /**
609 * Restore Yoast indexable table data for a post.
610 *
611 * @param int $post_id Post ID.
612 * @param array $columns Backed up column values.
613 * @param string|null $marker '0', '1', or null.
614 * @return bool
615 */
616 private static function restore_yoast_indexable_table($post_id, array $columns, $marker) {
617 global $wpdb;
618 if (!isset($wpdb)) {
619 return false;
620 }
621
622 $table = $wpdb->prefix . 'yoast_indexable';
623
624 // If marker is '0', the row was created by MetaSync -> delete row.
625 if ($marker === '0') {
626 $deleted = $wpdb->delete($table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
627 'object_id' => $post_id,
628 'object_type' => 'post',
629 ]);
630 return ($deleted !== false);
631 }
632
633 // Marker is '1' or absent -> per-column update
634 if (empty($columns)) {
635 return true;
636 }
637
638 $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
639 $table,
640 $columns,
641 [
642 'object_id' => $post_id,
643 'object_type' => 'post',
644 ]
645 );
646
647 if ($updated === false) {
648 return false;
649 }
650
651 // 0 affected rows: identical values (fine) or the row is gone (the
652 // original values now have nowhere to live — fail so the backups
653 // survive). Probe for the row to tell the two apart.
654 if ($updated === 0) {
655 $exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
656 "SELECT object_id FROM {$wpdb->prefix}yoast_indexable WHERE object_id = %d AND object_type = 'post'",
657 $post_id
658 ));
659 return ($exists !== null);
660 }
661
662 return true;
663 }
664
665 /**
666 * Restore Yoast taxonomy metadata in the raw wpseo_taxonomy_meta option.
667 *
668 * Writes the raw option directly, pruning null/absent fields and cleaning empty entries,
669 * then fires edited_term to trigger Yoast's indexable rebuild.
670 *
671 * @param int $term_id Term ID.
672 * @param string $taxonomy Taxonomy slug.
673 * @param array $fields Yoast field key => backed up value.
674 * @return bool
675 */
676 private static function restore_yoast_taxonomy_meta($term_id, $taxonomy, array $fields) {
677 $option = get_option('wpseo_taxonomy_meta', []);
678 if (!is_array($option)) {
679 return false;
680 }
681 if (!Metasync_Seo_Backup::db_read_succeeded()) {
682 return false;
683 }
684
685 $term_entry = $option[$taxonomy][$term_id] ?? [];
686 if (!is_array($term_entry)) {
687 $term_entry = [];
688 }
689
690 foreach ($fields as $yoast_key => $backup_val) {
691 if ($backup_val === null) {
692 unset($term_entry[$yoast_key]);
693 } else {
694 $term_entry[$yoast_key] = $backup_val;
695 }
696 }
697
698 $option_before = $option;
699
700 if (empty($term_entry)) {
701 if (isset($option[$taxonomy][$term_id])) {
702 unset($option[$taxonomy][$term_id]);
703 }
704 if (isset($option[$taxonomy]) && empty($option[$taxonomy])) {
705 unset($option[$taxonomy]);
706 }
707 } else {
708 if (!isset($option[$taxonomy]) || !is_array($option[$taxonomy])) {
709 $option[$taxonomy] = [];
710 }
711 $option[$taxonomy][$term_id] = $term_entry;
712 }
713
714 // Already at the target state: update_option() would return false for
715 // an unchanged value and wedge this object as failed on every retry,
716 // so short-circuit instead — and skip the edited_term churn too.
717 if ($option === $option_before) {
718 return true;
719 }
720
721 $saved = update_option('wpseo_taxonomy_meta', $option);
722
723 if ($saved === false) {
724 // False also covers the unchanged-value no-op; re-read to decide.
725 $after = get_option('wpseo_taxonomy_meta', []);
726 if ($after !== $option) {
727 return false;
728 }
729 }
730
731 // Trigger Yoast indexable cache rebuild after a real write.
732 $term_obj = get_term($term_id, $taxonomy);
733 $tt_id = ($term_obj && !is_wp_error($term_obj)) ? (int) $term_obj->term_taxonomy_id : 0;
734 do_action('edited_term', $term_id, $tt_id, $taxonomy);
735
736 return true;
737 }
738
739 // =========================================================================
740 // Batch Driver & Queue Processor (R2)
741 // =========================================================================
742
743 /**
744 * Get current batch progress data.
745 *
746 * @return array Progress status array.
747 */
748 public static function get_progress(): array {
749 $default = [
750 'total' => 0,
751 'processed' => 0,
752 'failed' => 0,
753 'fields_restored' => 0,
754 'status' => 'idle', // 'idle', 'running', 'completed', 'cancelled'
755 'started_at' => '',
756 ];
757
758 return wp_parse_args(get_option(self::PROGRESS_OPTION, []), $default);
759 }
760
761 /**
762 * Check if a batch restore is currently running.
763 *
764 * @return bool
765 */
766 public static function is_running(): bool {
767 $progress = self::get_progress();
768 return ($progress['status'] === 'running');
769 }
770
771 /**
772 * Start a new bulk restore run.
773 *
774 * 1. Turns consent OFF immediately to prevent racing syncs from re-overwriting.
775 * 2. Builds queue of all post and term IDs holding backup meta.
776 * 3. Initializes progress option and schedules cron fallback.
777 *
778 * @return array Progress data.
779 */
780 public static function start_batch(): array {
781 if (self::is_running()) {
782 return self::get_progress();
783 }
784
785 // Turn consent OFF before the queue is built, not after. Objects whose
786 // backups are recorded between the snapshot and the disable would be
787 // missing from the queue while the run still reports "completed"; with
788 // consent already off when the snapshot runs, nothing new can start
789 // writing its way into that gap.
790 //
791 // If it cannot be turned off, a concurrent OTTO sync could re-overwrite
792 // just-restored objects after their backups are deleted — unrecoverable.
793 // Abort instead.
794 $consent_was_enabled = class_exists('Metasync_Seo_Backup')
795 && Metasync_Seo_Backup::is_enabled();
796 if (!self::disable_consent()) {
797 return [
798 'total' => 0,
799 'processed' => 0,
800 'failed' => 0,
801 'fields_restored' => 0,
802 'status' => 'idle',
803 'error' => __('Could not disable syncing to other SEO plugins; restore aborted.', 'metasync'),
804 'started_at' => current_time('mysql'),
805 ];
806 }
807
808 $queue = self::query_objects_to_restore();
809
810 // Nothing to restore: put the consent toggle back the way the user had
811 // it — an empty run must not change their setting — and report
812 // completion. When the toggle cannot be put back, say so instead of
813 // reporting a clean finish with the setting silently left off.
814 if (empty($queue)) {
815 $result = [
816 'total' => 0,
817 'processed' => 0,
818 'failed' => 0,
819 'fields_restored' => 0,
820 'status' => 'completed',
821 'started_at' => current_time('mysql'),
822 ];
823
824 if ($consent_was_enabled && !self::enable_consent()) {
825 $result['error'] = __('Nothing to restore, but syncing to other SEO plugins could not be re-enabled.', 'metasync');
826 }
827
828 return $result;
829 }
830
831 update_option(self::QUEUE_OPTION, $queue, false);
832
833 $progress = [
834 'total' => count($queue),
835 'processed' => 0,
836 'failed' => 0,
837 'fields_restored' => 0,
838 'status' => 'running',
839 'started_at' => current_time('mysql'),
840 ];
841 update_option(self::PROGRESS_OPTION, $progress, false);
842
843 // Schedule cron fallback
844 if (!wp_next_scheduled(self::CRON_HOOK)) {
845 wp_schedule_event(time() + 120, 'metasync_every_2_minutes', self::CRON_HOOK);
846 }
847
848 return self::get_progress();
849 }
850
851 /**
852 * Cancel a running batch restore.
853 *
854 * @param string $error Optional reason surfaced to the admin UI when the
855 * run was cancelled by the system rather than the user.
856 * @return array Progress data after cancellation.
857 */
858 public static function cancel_batch($error = ''): array {
859 $progress = self::get_progress();
860 $was_running = ($progress['status'] === 'running');
861 $progress['status'] = 'cancelled';
862 if ($error !== '') {
863 $progress['error'] = $error;
864 }
865 update_option(self::PROGRESS_OPTION, $progress, false);
866
867 delete_option(self::QUEUE_OPTION);
868 self::release_lock();
869 wp_clear_scheduled_hook(self::CRON_HOOK);
870
871 if ($was_running) {
872 self::track_run_finished($progress, $error !== '' ? 'cancelled_by_system' : 'cancelled');
873 }
874
875 return $progress;
876 }
877
878 /**
879 * Process one AJAX tick of the restore batch.
880 *
881 * @return array Updated progress data.
882 */
883 public static function process_ajax_tick(): array {
884 $progress = self::get_progress();
885
886 if ($progress['status'] !== 'running') {
887 return $progress;
888 }
889
890 // Consent is re-asserted on every tick, not just at start: the toggle
891 // can be switched back on in another tab while the run is in flight,
892 // and a sync that slips through between restores can re-overwrite a
893 // restored value whose backup row was already deleted — unrecoverable.
894 // If it cannot be re-disabled, cancel rather than restore with syncs
895 // live.
896 if (!self::disable_consent()) {
897 self::cancel_batch(__('Could not keep syncing to other SEO plugins disabled; restore cancelled.', 'metasync'));
898 return self::get_progress();
899 }
900
901 if (!self::acquire_lock()) {
902 return $progress;
903 }
904
905 try {
906 $queue = get_option(self::QUEUE_OPTION, []);
907
908 if (empty($queue)) {
909 self::complete_batch();
910 return self::get_progress();
911 }
912
913 $batch_size = self::DEFAULT_BATCH_SIZE;
914 $batch = array_splice($queue, 0, $batch_size);
915
916 // Same deadline the cron path honours: 25 Yoast-heavy terms can
917 // each fire edited_term (an indexable rebuild), so a fixed item
918 // count alone can overrun max_execution_time.
919 $start = time();
920 $time_limit = self::get_safe_time_limit();
921 $done = 0;
922
923 for ($i = 0, $n = count($batch); $i < $n; $i++) {
924 $current = get_option(self::PROGRESS_OPTION, []);
925 if (($current['status'] ?? '') !== 'running') {
926 break;
927 }
928
929 self::restore_single_item($batch[$i], $progress);
930 $done = $i + 1;
931
932 if ((time() - $start) >= $time_limit) {
933 break;
934 }
935 }
936
937 // Items the deadline cut short go back to the front of the queue.
938 if ($done < count($batch)) {
939 $queue = array_merge(array_slice($batch, $done), $queue);
940 }
941
942 $status_now = get_option(self::PROGRESS_OPTION, []);
943 if (($status_now['status'] ?? '') === 'running') {
944 // The queue is one option row rewritten whole every tick. A
945 // persist that fails silently leaves the next tick re-reading
946 // the items this one just restored — as no-op "successes" that
947 // never drain the queue. Cancel with the error instead.
948 if (update_option(self::QUEUE_OPTION, $queue, false) === false) {
949 self::cancel_batch(__('Could not save the restore queue; restore cancelled.', 'metasync'));
950 return self::get_progress();
951 }
952 self::merge_progress_counters($progress);
953
954 if (empty($queue)) {
955 self::complete_batch();
956 return self::get_progress();
957 }
958 }
959 } finally {
960 self::release_lock();
961 }
962
963 return self::get_progress();
964 }
965
966 /**
967 * Process batch tick via Cron fallback.
968 */
969 public static function process_batch_tick(): void {
970 $progress = self::get_progress();
971
972 if ($progress['status'] !== 'running') {
973 return;
974 }
975
976 // Same re-assertion as the AJAX tick: consent is kept off for the
977 // whole run, and a run that cannot keep it off is cancelled rather
978 // than allowed to restore with syncs live.
979 if (!self::disable_consent()) {
980 self::cancel_batch(__('Could not keep syncing to other SEO plugins disabled; restore cancelled.', 'metasync'));
981 return;
982 }
983
984 if (!self::acquire_lock()) {
985 return;
986 }
987
988 try {
989 $queue = get_option(self::QUEUE_OPTION, []);
990
991 if (empty($queue)) {
992 self::complete_batch();
993 return;
994 }
995
996 $start = time();
997 $time_limit = self::get_safe_time_limit();
998
999 while (!empty($queue) && (time() - $start) < $time_limit) {
1000 $current = get_option(self::PROGRESS_OPTION, []);
1001 if (($current['status'] ?? '') !== 'running') {
1002 break;
1003 }
1004
1005 $batch = array_splice($queue, 0, self::DEFAULT_BATCH_SIZE);
1006 $done = 0;
1007
1008 // Same per-item checks as the AJAX tick: 25 Yoast-heavy terms
1009 // can each fire edited_term (an indexable rebuild), so a fixed
1010 // item count alone can overrun max_execution_time mid-batch,
1011 // and a cancellation must be honoured between items.
1012 foreach ($batch as $item) {
1013 $current = get_option(self::PROGRESS_OPTION, []);
1014 if (($current['status'] ?? '') !== 'running') {
1015 break;
1016 }
1017
1018 self::restore_single_item($item, $progress);
1019 $done++;
1020
1021 if ((time() - $start) >= $time_limit) {
1022 break;
1023 }
1024 }
1025
1026 // Items the deadline or a cancellation cut short go back to the
1027 // front of the queue.
1028 if ($done < count($batch)) {
1029 $queue = array_merge(array_slice($batch, $done), $queue);
1030 }
1031
1032 self::merge_progress_counters($progress);
1033 }
1034
1035 $status_now = get_option(self::PROGRESS_OPTION, []);
1036 if (($status_now['status'] ?? '') === 'running') {
1037 // Same persist check as the AJAX tick: a queue that will not
1038 // save would loop the next ticks over the same first items
1039 // forever, never finishing.
1040 if (update_option(self::QUEUE_OPTION, $queue, false) === false) {
1041 self::cancel_batch(__('Could not save the restore queue; restore cancelled.', 'metasync'));
1042 return;
1043 }
1044
1045 if (empty($queue)) {
1046 self::complete_batch();
1047 }
1048 }
1049 } finally {
1050 self::release_lock();
1051 }
1052 }
1053
1054 /**
1055 * Restore a single queued item with error handling.
1056 *
1057 * @param array $item ['type' => 'post'|'term', 'id' => int]
1058 * @param array &$progress Progress array by reference.
1059 */
1060 private static function restore_single_item(array $item, array &$progress): void {
1061 try {
1062 $type = $item['type'] ?? '';
1063 $id = (int) ($item['id'] ?? 0);
1064
1065 $res = self::restore_object($type, $id);
1066 $progress['processed']++;
1067 if (!empty($res['success'])) {
1068 $progress['fields_restored'] += (int) $res['restored_count'];
1069 } else {
1070 $progress['failed']++;
1071 }
1072 } catch (\Throwable $e) {
1073 $progress['processed']++;
1074 $progress['failed']++;
1075 if (class_exists('Metasync_Error_Logger')) {
1076 Metasync_Error_Logger::log(
1077 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
1078 Metasync_Error_Logger::SEVERITY_ERROR,
1079 'Failed restoring SEO backup for ' . ($item['type'] ?? '') . ' #' . ($item['id'] ?? ''),
1080 ['error' => $e->getMessage()]
1081 );
1082 }
1083 }
1084 }
1085
1086 /**
1087 * Complete the batch restore run and record sync history summary.
1088 */
1089 private static function complete_batch(): void {
1090 $progress = self::get_progress();
1091 $finalized = false;
1092
1093 if ($progress['status'] === 'running') {
1094 $progress['status'] = 'completed';
1095 update_option(self::PROGRESS_OPTION, $progress, false);
1096 $finalized = true;
1097 }
1098
1099 delete_option(self::QUEUE_OPTION);
1100 self::release_lock();
1101 wp_clear_scheduled_hook(self::CRON_HOOK);
1102
1103 if ($finalized) {
1104 self::record_history_summary($progress);
1105 self::track_run_finished($progress, 'completed');
1106 }
1107 }
1108
1109 /**
1110 * Consent-gated analytics: undoing OTTO's writes is a pre-uninstall churn
1111 * signal. Sent only on the running -> finished transition, so a repeated
1112 * cancel or completion cannot double-count.
1113 *
1114 * @param array $progress Final progress data.
1115 * @param string $status completed | cancelled | cancelled_by_system.
1116 */
1117 private static function track_run_finished(array $progress, string $status): void {
1118 Metasync_PostHog::feature('seo_restore_completed', [
1119 'status' => $status,
1120 'total' => (int) ($progress['total'] ?? 0),
1121 'processed' => (int) ($progress['processed'] ?? 0),
1122 'failed' => (int) ($progress['failed'] ?? 0),
1123 'fields_restored' => (int) ($progress['fields_restored'] ?? 0),
1124 ]);
1125 }
1126
1127 /**
1128 * Record one summary row in metasync_sync_history for the entire restore run.
1129 *
1130 * @param array $progress Final progress data.
1131 */
1132 private static function record_history_summary(array $progress): void {
1133 // Nothing in the boot chain loads this class — its other require sites
1134 // are inside method bodies that only run on unrelated pages — so
1135 // without this the R4 audit row silently never appears.
1136 if (!class_exists('Metasync_Sync_History_Database')) {
1137 $history_class = plugin_dir_path(dirname(__FILE__)) . 'sync-history/class-metasync-sync-history-database.php';
1138 if (!is_readable($history_class)) {
1139 return;
1140 }
1141 require_once $history_class;
1142 }
1143
1144 try {
1145 $history = new Metasync_Sync_History_Database();
1146 $product = class_exists('Metasync') ? Metasync::get_effective_plugin_name() : 'Search Atlas';
1147
1148 $history->add([
1149 'title' => sprintf(
1150 '%s restored original SEO values for %d objects (%d field(s) restored)',
1151 $product,
1152 (int) ($progress['processed'] - $progress['failed']),
1153 (int) ($progress['fields_restored'] ?? 0)
1154 ),
1155 'source' => 'SEO Plugin Sync',
1156 'status' => 'published',
1157 'content_type' => 'restore',
1158 'url' => null,
1159 'meta_data' => wp_json_encode([
1160 'total_objects' => $progress['total'],
1161 'processed' => $progress['processed'],
1162 'failed' => $progress['failed'],
1163 'fields_restored' => $progress['fields_restored'] ?? 0,
1164 'started_at' => $progress['started_at'],
1165 'completed_at' => current_time('mysql'),
1166 ]),
1167 'created_at' => current_time('mysql'),
1168 ]);
1169 } catch (\Throwable $e) {
1170 // Never let a logging failure break the operation.
1171 }
1172 }
1173
1174 /**
1175 * Build the list of all object IDs needing restore using keyset pagination.
1176 *
1177 * @return array Array of ['type' => 'post'|'term', 'id' => int]
1178 */
1179 public static function query_objects_to_restore(): array {
1180 global $wpdb;
1181 if (!isset($wpdb)) {
1182 return [];
1183 }
1184
1185 $queue = [];
1186 $prefix_like = $wpdb->esc_like(Metasync_Seo_Backup::BACKUP_META_PREFIX) . '%';
1187
1188 // 1. Posts with backup meta (keyset paginated by post_id)
1189 $last_id = 0;
1190 do {
1191 $posts = $wpdb->get_col($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
1192 "SELECT DISTINCT post_id
1193 FROM {$wpdb->postmeta}
1194 WHERE meta_key LIKE %s AND post_id > %d
1195 ORDER BY post_id ASC
1196 LIMIT %d",
1197 $prefix_like,
1198 $last_id,
1199 self::QUERY_PAGE_SIZE
1200 ));
1201
1202 if (!empty($posts)) {
1203 $int_posts = array_map('intval', $posts);
1204 foreach ($int_posts as $pid) {
1205 $queue[] = ['type' => 'post', 'id' => $pid];
1206 }
1207 $last_id = end($int_posts);
1208 }
1209 } while (!empty($posts) && count($posts) === self::QUERY_PAGE_SIZE);
1210
1211 // 2. Terms with backup meta (keyset paginated by term_id)
1212 $last_id = 0;
1213 do {
1214 $terms = $wpdb->get_col($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- restore tool — exact-row bulk migration including third-party tables; no bulk WordPress API
1215 "SELECT DISTINCT term_id
1216 FROM {$wpdb->termmeta}
1217 WHERE meta_key LIKE %s AND term_id > %d
1218 ORDER BY term_id ASC
1219 LIMIT %d",
1220 $prefix_like,
1221 $last_id,
1222 self::QUERY_PAGE_SIZE
1223 ));
1224
1225 if (!empty($terms)) {
1226 $int_terms = array_map('intval', $terms);
1227 foreach ($int_terms as $tid) {
1228 $queue[] = ['type' => 'term', 'id' => $tid];
1229 }
1230 $last_id = end($int_terms);
1231 }
1232 } while (!empty($terms) && count($terms) === self::QUERY_PAGE_SIZE);
1233
1234 return $queue;
1235 }
1236
1237 /**
1238 * Calculate safe time limit for cron processing.
1239 *
1240 * @param int $max_execution_time
1241 * @return int
1242 */
1243 protected static function get_safe_time_limit(int $max_execution_time = -1): int {
1244 if ($max_execution_time < 0) {
1245 $max_execution_time = (int) ini_get('max_execution_time');
1246 }
1247
1248 if ($max_execution_time === 0) {
1249 return self::CRON_TIME_LIMIT;
1250 }
1251
1252 $safe = $max_execution_time - self::TIME_SAFETY_MARGIN;
1253 return max(1, min(self::CRON_TIME_LIMIT, $safe));
1254 }
1255
1256 /**
1257 * Atomically acquire the batch lock.
1258 */
1259 private static function acquire_lock(): bool {
1260 $existing = get_option(self::LOCK_OPTION);
1261
1262 if ($existing && (time() - (int) $existing) < self::LOCK_STALE_AFTER) {
1263 return false;
1264 }
1265
1266 if ($existing) {
1267 delete_option(self::LOCK_OPTION);
1268 }
1269
1270 return (bool) add_option(self::LOCK_OPTION, time(), '', false);
1271 }
1272
1273 /**
1274 * Release the batch lock.
1275 */
1276 private static function release_lock(): void {
1277 delete_option(self::LOCK_OPTION);
1278 }
1279
1280 /**
1281 * Persist progress counters without clobbering concurrent status changes.
1282 */
1283 private static function merge_progress_counters(array $progress): void {
1284 $fresh = get_option(self::PROGRESS_OPTION, []);
1285 if (!is_array($fresh) || ($fresh['status'] ?? '') !== 'running') {
1286 return;
1287 }
1288
1289 $fresh['processed'] = $progress['processed'];
1290 $fresh['failed'] = $progress['failed'];
1291 $fresh['fields_restored'] = $progress['fields_restored'];
1292 update_option(self::PROGRESS_OPTION, $fresh, false);
1293 }
1294 }
1295