PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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.1, at includes/class-metasync-seo-restore.php

1,271 lines 40.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 $progress['status'] = 'cancelled';
861 if ($error !== '') {
862 $progress['error'] = $error;
863 }
864 update_option(self::PROGRESS_OPTION, $progress, false);
865
866 delete_option(self::QUEUE_OPTION);
867 self::release_lock();
868 wp_clear_scheduled_hook(self::CRON_HOOK);
869
870 return $progress;
871 }
872
873 /**
874 * Process one AJAX tick of the restore batch.
875 *
876 * @return array Updated progress data.
877 */
878 public static function process_ajax_tick(): array {
879 $progress = self::get_progress();
880
881 if ($progress['status'] !== 'running') {
882 return $progress;
883 }
884
885 // Consent is re-asserted on every tick, not just at start: the toggle
886 // can be switched back on in another tab while the run is in flight,
887 // and a sync that slips through between restores can re-overwrite a
888 // restored value whose backup row was already deleted — unrecoverable.
889 // If it cannot be re-disabled, cancel rather than restore with syncs
890 // live.
891 if (!self::disable_consent()) {
892 self::cancel_batch(__('Could not keep syncing to other SEO plugins disabled; restore cancelled.', 'metasync'));
893 return self::get_progress();
894 }
895
896 if (!self::acquire_lock()) {
897 return $progress;
898 }
899
900 try {
901 $queue = get_option(self::QUEUE_OPTION, []);
902
903 if (empty($queue)) {
904 self::complete_batch();
905 return self::get_progress();
906 }
907
908 $batch_size = self::DEFAULT_BATCH_SIZE;
909 $batch = array_splice($queue, 0, $batch_size);
910
911 // Same deadline the cron path honours: 25 Yoast-heavy terms can
912 // each fire edited_term (an indexable rebuild), so a fixed item
913 // count alone can overrun max_execution_time.
914 $start = time();
915 $time_limit = self::get_safe_time_limit();
916 $done = 0;
917
918 for ($i = 0, $n = count($batch); $i < $n; $i++) {
919 $current = get_option(self::PROGRESS_OPTION, []);
920 if (($current['status'] ?? '') !== 'running') {
921 break;
922 }
923
924 self::restore_single_item($batch[$i], $progress);
925 $done = $i + 1;
926
927 if ((time() - $start) >= $time_limit) {
928 break;
929 }
930 }
931
932 // Items the deadline cut short go back to the front of the queue.
933 if ($done < count($batch)) {
934 $queue = array_merge(array_slice($batch, $done), $queue);
935 }
936
937 $status_now = get_option(self::PROGRESS_OPTION, []);
938 if (($status_now['status'] ?? '') === 'running') {
939 // The queue is one option row rewritten whole every tick. A
940 // persist that fails silently leaves the next tick re-reading
941 // the items this one just restored — as no-op "successes" that
942 // never drain the queue. Cancel with the error instead.
943 if (update_option(self::QUEUE_OPTION, $queue, false) === false) {
944 self::cancel_batch(__('Could not save the restore queue; restore cancelled.', 'metasync'));
945 return self::get_progress();
946 }
947 self::merge_progress_counters($progress);
948
949 if (empty($queue)) {
950 self::complete_batch();
951 return self::get_progress();
952 }
953 }
954 } finally {
955 self::release_lock();
956 }
957
958 return self::get_progress();
959 }
960
961 /**
962 * Process batch tick via Cron fallback.
963 */
964 public static function process_batch_tick(): void {
965 $progress = self::get_progress();
966
967 if ($progress['status'] !== 'running') {
968 return;
969 }
970
971 // Same re-assertion as the AJAX tick: consent is kept off for the
972 // whole run, and a run that cannot keep it off is cancelled rather
973 // than allowed to restore with syncs live.
974 if (!self::disable_consent()) {
975 self::cancel_batch(__('Could not keep syncing to other SEO plugins disabled; restore cancelled.', 'metasync'));
976 return;
977 }
978
979 if (!self::acquire_lock()) {
980 return;
981 }
982
983 try {
984 $queue = get_option(self::QUEUE_OPTION, []);
985
986 if (empty($queue)) {
987 self::complete_batch();
988 return;
989 }
990
991 $start = time();
992 $time_limit = self::get_safe_time_limit();
993
994 while (!empty($queue) && (time() - $start) < $time_limit) {
995 $current = get_option(self::PROGRESS_OPTION, []);
996 if (($current['status'] ?? '') !== 'running') {
997 break;
998 }
999
1000 $batch = array_splice($queue, 0, self::DEFAULT_BATCH_SIZE);
1001 $done = 0;
1002
1003 // Same per-item checks as the AJAX tick: 25 Yoast-heavy terms
1004 // can each fire edited_term (an indexable rebuild), so a fixed
1005 // item count alone can overrun max_execution_time mid-batch,
1006 // and a cancellation must be honoured between items.
1007 foreach ($batch as $item) {
1008 $current = get_option(self::PROGRESS_OPTION, []);
1009 if (($current['status'] ?? '') !== 'running') {
1010 break;
1011 }
1012
1013 self::restore_single_item($item, $progress);
1014 $done++;
1015
1016 if ((time() - $start) >= $time_limit) {
1017 break;
1018 }
1019 }
1020
1021 // Items the deadline or a cancellation cut short go back to the
1022 // front of the queue.
1023 if ($done < count($batch)) {
1024 $queue = array_merge(array_slice($batch, $done), $queue);
1025 }
1026
1027 self::merge_progress_counters($progress);
1028 }
1029
1030 $status_now = get_option(self::PROGRESS_OPTION, []);
1031 if (($status_now['status'] ?? '') === 'running') {
1032 // Same persist check as the AJAX tick: a queue that will not
1033 // save would loop the next ticks over the same first items
1034 // forever, never finishing.
1035 if (update_option(self::QUEUE_OPTION, $queue, false) === false) {
1036 self::cancel_batch(__('Could not save the restore queue; restore cancelled.', 'metasync'));
1037 return;
1038 }
1039
1040 if (empty($queue)) {
1041 self::complete_batch();
1042 }
1043 }
1044 } finally {
1045 self::release_lock();
1046 }
1047 }
1048
1049 /**
1050 * Restore a single queued item with error handling.
1051 *
1052 * @param array $item ['type' => 'post'|'term', 'id' => int]
1053 * @param array &$progress Progress array by reference.
1054 */
1055 private static function restore_single_item(array $item, array &$progress): void {
1056 try {
1057 $type = $item['type'] ?? '';
1058 $id = (int) ($item['id'] ?? 0);
1059
1060 $res = self::restore_object($type, $id);
1061 $progress['processed']++;
1062 if (!empty($res['success'])) {
1063 $progress['fields_restored'] += (int) $res['restored_count'];
1064 } else {
1065 $progress['failed']++;
1066 }
1067 } catch (\Throwable $e) {
1068 $progress['processed']++;
1069 $progress['failed']++;
1070 if (class_exists('Metasync_Error_Logger')) {
1071 Metasync_Error_Logger::log(
1072 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
1073 Metasync_Error_Logger::SEVERITY_ERROR,
1074 'Failed restoring SEO backup for ' . ($item['type'] ?? '') . ' #' . ($item['id'] ?? ''),
1075 ['error' => $e->getMessage()]
1076 );
1077 }
1078 }
1079 }
1080
1081 /**
1082 * Complete the batch restore run and record sync history summary.
1083 */
1084 private static function complete_batch(): void {
1085 $progress = self::get_progress();
1086 $finalized = false;
1087
1088 if ($progress['status'] === 'running') {
1089 $progress['status'] = 'completed';
1090 update_option(self::PROGRESS_OPTION, $progress, false);
1091 $finalized = true;
1092 }
1093
1094 delete_option(self::QUEUE_OPTION);
1095 self::release_lock();
1096 wp_clear_scheduled_hook(self::CRON_HOOK);
1097
1098 if ($finalized) {
1099 self::record_history_summary($progress);
1100 }
1101 }
1102
1103 /**
1104 * Record one summary row in metasync_sync_history for the entire restore run.
1105 *
1106 * @param array $progress Final progress data.
1107 */
1108 private static function record_history_summary(array $progress): void {
1109 // Nothing in the boot chain loads this class — its other require sites
1110 // are inside method bodies that only run on unrelated pages — so
1111 // without this the R4 audit row silently never appears.
1112 if (!class_exists('Metasync_Sync_History_Database')) {
1113 $history_class = plugin_dir_path(dirname(__FILE__)) . 'sync-history/class-metasync-sync-history-database.php';
1114 if (!is_readable($history_class)) {
1115 return;
1116 }
1117 require_once $history_class;
1118 }
1119
1120 try {
1121 $history = new Metasync_Sync_History_Database();
1122 $product = class_exists('Metasync') ? Metasync::get_effective_plugin_name() : 'Search Atlas';
1123
1124 $history->add([
1125 'title' => sprintf(
1126 '%s restored original SEO values for %d objects (%d field(s) restored)',
1127 $product,
1128 (int) ($progress['processed'] - $progress['failed']),
1129 (int) ($progress['fields_restored'] ?? 0)
1130 ),
1131 'source' => 'SEO Plugin Sync',
1132 'status' => 'published',
1133 'content_type' => 'restore',
1134 'url' => null,
1135 'meta_data' => wp_json_encode([
1136 'total_objects' => $progress['total'],
1137 'processed' => $progress['processed'],
1138 'failed' => $progress['failed'],
1139 'fields_restored' => $progress['fields_restored'] ?? 0,
1140 'started_at' => $progress['started_at'],
1141 'completed_at' => current_time('mysql'),
1142 ]),
1143 'created_at' => current_time('mysql'),
1144 ]);
1145 } catch (\Throwable $e) {
1146 // Never let a logging failure break the operation.
1147 }
1148 }
1149
1150 /**
1151 * Build the list of all object IDs needing restore using keyset pagination.
1152 *
1153 * @return array Array of ['type' => 'post'|'term', 'id' => int]
1154 */
1155 public static function query_objects_to_restore(): array {
1156 global $wpdb;
1157 if (!isset($wpdb)) {
1158 return [];
1159 }
1160
1161 $queue = [];
1162 $prefix_like = $wpdb->esc_like(Metasync_Seo_Backup::BACKUP_META_PREFIX) . '%';
1163
1164 // 1. Posts with backup meta (keyset paginated by post_id)
1165 $last_id = 0;
1166 do {
1167 $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
1168 "SELECT DISTINCT post_id
1169 FROM {$wpdb->postmeta}
1170 WHERE meta_key LIKE %s AND post_id > %d
1171 ORDER BY post_id ASC
1172 LIMIT %d",
1173 $prefix_like,
1174 $last_id,
1175 self::QUERY_PAGE_SIZE
1176 ));
1177
1178 if (!empty($posts)) {
1179 $int_posts = array_map('intval', $posts);
1180 foreach ($int_posts as $pid) {
1181 $queue[] = ['type' => 'post', 'id' => $pid];
1182 }
1183 $last_id = end($int_posts);
1184 }
1185 } while (!empty($posts) && count($posts) === self::QUERY_PAGE_SIZE);
1186
1187 // 2. Terms with backup meta (keyset paginated by term_id)
1188 $last_id = 0;
1189 do {
1190 $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
1191 "SELECT DISTINCT term_id
1192 FROM {$wpdb->termmeta}
1193 WHERE meta_key LIKE %s AND term_id > %d
1194 ORDER BY term_id ASC
1195 LIMIT %d",
1196 $prefix_like,
1197 $last_id,
1198 self::QUERY_PAGE_SIZE
1199 ));
1200
1201 if (!empty($terms)) {
1202 $int_terms = array_map('intval', $terms);
1203 foreach ($int_terms as $tid) {
1204 $queue[] = ['type' => 'term', 'id' => $tid];
1205 }
1206 $last_id = end($int_terms);
1207 }
1208 } while (!empty($terms) && count($terms) === self::QUERY_PAGE_SIZE);
1209
1210 return $queue;
1211 }
1212
1213 /**
1214 * Calculate safe time limit for cron processing.
1215 *
1216 * @param int $max_execution_time
1217 * @return int
1218 */
1219 protected static function get_safe_time_limit(int $max_execution_time = -1): int {
1220 if ($max_execution_time < 0) {
1221 $max_execution_time = (int) ini_get('max_execution_time');
1222 }
1223
1224 if ($max_execution_time === 0) {
1225 return self::CRON_TIME_LIMIT;
1226 }
1227
1228 $safe = $max_execution_time - self::TIME_SAFETY_MARGIN;
1229 return max(1, min(self::CRON_TIME_LIMIT, $safe));
1230 }
1231
1232 /**
1233 * Atomically acquire the batch lock.
1234 */
1235 private static function acquire_lock(): bool {
1236 $existing = get_option(self::LOCK_OPTION);
1237
1238 if ($existing && (time() - (int) $existing) < self::LOCK_STALE_AFTER) {
1239 return false;
1240 }
1241
1242 if ($existing) {
1243 delete_option(self::LOCK_OPTION);
1244 }
1245
1246 return (bool) add_option(self::LOCK_OPTION, time(), '', false);
1247 }
1248
1249 /**
1250 * Release the batch lock.
1251 */
1252 private static function release_lock(): void {
1253 delete_option(self::LOCK_OPTION);
1254 }
1255
1256 /**
1257 * Persist progress counters without clobbering concurrent status changes.
1258 */
1259 private static function merge_progress_counters(array $progress): void {
1260 $fresh = get_option(self::PROGRESS_OPTION, []);
1261 if (!is_array($fresh) || ($fresh['status'] ?? '') !== 'running') {
1262 return;
1263 }
1264
1265 $fresh['processed'] = $progress['processed'];
1266 $fresh['failed'] = $progress['failed'];
1267 $fresh['fields_restored'] = $progress['fields_restored'];
1268 update_option(self::PROGRESS_OPTION, $fresh, false);
1269 }
1270 }
1271