PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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
← All changes | includes/class-metasync-term-plugin-sync.php +367 -14 2.6.22 → trunk View file →
@@ -18,8 +18,26 @@
18 18
19 19 class Metasync_Term_Plugin_Sync {
20 20
21 21 /**
22 + * OTTO-generated term fields and their matching Persistence settings.
23 + *
24 + * Manually entered term fields are deliberately not gated; callers mark
25 + * only the comprehensive OTTO sync as generated below.
26 + *
27 + * @var array<string,string>
28 + */
29 + private const OTTO_PERSISTENCE_KEYS = [
30 + 'title' => 'meta_title',
31 + 'desc' => 'meta_description',
32 + 'og_title' => 'og_title',
33 + 'og_desc' => 'og_description',
34 + 'twitter_title' => 'twitter_title',
35 + 'twitter_desc' => 'twitter_description',
36 + 'canonical' => 'canonical_url',
37 + ];
38 +
39 + /**
22 40 * Singleton instance.
23 41 *
24 42 * @var self|null
25 43 */
@@ -51,11 +69,16 @@
51 69 * @param int $term_id Term ID.
52 70 * @param string $taxonomy Taxonomy slug (unused by the plugins but kept
53 71 * in the signature for future use / logging).
54 72 * @param array $data Canonical key/value pairs.
73 + * @param bool $otto_generated Whether the values came from OTTO. A single
74 + * call must not mix OTTO-generated and manually
75 + * entered fields: the flag applies to every entry
76 + * in $data, so a mixed call could only gate all of
77 + * them or none. Split the call instead.
55 78 * @return array Results keyed by plugin: ['yoast'=>bool,'rankmath'=>bool,'aioseo'=>bool].
56 79 */
57 - public function sync_term($term_id, $taxonomy, array $data) {
80 + public function sync_term($term_id, $taxonomy, array $data, $otto_generated = false) {
58 81 // Explicit static recursion guard — prevents re-entrant calls for the same
59 82 // term (e.g. if a term_meta hook triggers another sync_term() call).
60 83 static $syncing = [];
61 84 if (!empty($syncing[$term_id])) {
@@ -69,8 +92,27 @@
69 92 if ($term_id <= 0 || empty($data)) {
70 93 return $results;
71 94 }
72 95
96 + // OTTO-generated values may reach third-party storage only while
97 + // their matching Persistence setting is enabled. The class_exists
98 + // guard is fail-closed so a partial install cannot authorise a
99 + // permanent write. Manual term updates use the default false flag
100 + // and remain ungated, matching the post-side bridge semantics.
101 + if ($otto_generated) {
102 + foreach (self::OTTO_PERSISTENCE_KEYS as $field => $setting) {
103 + if (array_key_exists($field, $data)
104 + && (!class_exists('Metasync_Otto_Persistence_Settings')
105 + || !Metasync_Otto_Persistence_Settings::should_persist($setting))) {
106 + unset($data[$field]);
107 + }
108 + }
109 +
110 + if (empty($data)) {
111 + return $results;
112 + }
113 + }
114 +
73 115 // Never mirror a corrupted or non-URL canonical into third-party
74 116 // storage — a nested-array value cast with (string) becomes the
75 117 // literal "Array" and propagates into Yoast/RankMath/AIOSEO.
76 118 if (array_key_exists('canonical', $data)) {
@@ -148,8 +190,122 @@
148 190 // Per-plugin sync
149 191 // ------------------------------------------------------------------
150 192
151 193 /**
194 + * Whether third-party SEO storage may be written at all.
195 + *
196 + * The site owner's consent switch. Terms are covered by it on exactly the
197 + * same terms as posts — a category's Rank Math title is another plugin's
198 + * data just as much as a page's is.
199 + *
200 + * @return bool
201 + */
202 + private function third_party_writes_allowed() {
203 + return class_exists('Metasync_Seo_Backup') && Metasync_Seo_Backup::is_enabled();
204 + }
205 +
206 + /**
207 + * Write one third-party term-meta field, preserving what it held before.
208 + *
209 + * The backup row lives in term meta, under the same key naming posts use.
210 + * Post meta and term meta are separate tables, so the two cannot collide
211 + * and the helper stays object-type agnostic.
212 + *
213 + * @param int $term_id Term ID.
214 + * @param string $key Third-party term meta key.
215 + * @param mixed $value Value to write.
216 + * @return bool True when the write happened.
217 + */
218 + private function write_term_field($term_id, $key, $value) {
219 + return class_exists('Metasync_Seo_Backup')
220 + && Metasync_Seo_Backup::write_term_meta($term_id, $key, $value);
221 + }
222 +
223 + /**
224 + * Backup field name for one Yoast taxonomy-meta entry.
225 + *
226 + * Yoast's `wpseo_taxonomy_meta` option nests taxonomy → term ID → field, so
227 + * the same term ID can hold a different original under each taxonomy it
228 + * belongs to. The backup rows all live on the term, which has no such
229 + * nesting, so the taxonomy has to be carried in the field name or the two
230 + * originals collide and write-once keeps only the first.
231 + *
232 + * @param string $taxonomy Taxonomy slug.
233 + * @param string $yoast_key Yoast field name, e.g. 'wpseo_title'.
234 + * @return string
235 + */
236 + private static function yoast_tax_backup_field($taxonomy, $yoast_key) {
237 + return 'yoast_tax_' . $taxonomy . '_' . $yoast_key;
238 + }
239 +
240 + /**
241 + * Carry a backup written under the old, taxonomy-less key onto the new one.
242 + *
243 + * Earlier builds stored these as `yoast_tax_{field}`, with no taxonomy in
244 + * the key. Sites that ran one of those hold real customer originals there.
245 + * Left alone, the next sync finds nothing at the new key and backs up
246 + * whatever is in the option now — which is the value the previous sync
247 + * wrote. The original would still exist, under a key nothing reads, while
248 + * the backup that a restore trusts would hold this plugin's own output.
249 + *
250 + * Copying it across first makes the new key win on its own merits: the
251 + * legacy value is the older, truer one, and backups are write-once, so a
252 + * later call cannot displace it.
253 + *
254 + * @param int $term_id Term ID.
255 + * @param string $taxonomy Taxonomy slug.
256 + * @param string $yoast_key Yoast field name.
257 + */
258 + private static function adopt_legacy_yoast_tax_backup($term_id, $taxonomy, $yoast_key) {
259 + $scoped = self::yoast_tax_backup_field($taxonomy, $yoast_key);
260 + $legacy = 'yoast_tax_' . $yoast_key;
261 +
262 + $existing_scoped = Metasync_Seo_Backup::read_backup('term', $term_id, $scoped);
263 + if (!empty($existing_scoped['exists'])) {
264 + return;
265 + }
266 +
267 + $legacy_backup = Metasync_Seo_Backup::read_backup('term', $term_id, $legacy);
268 + if (empty($legacy_backup['exists'])) {
269 + return;
270 + }
271 +
272 + Metasync_Seo_Backup::record_marker('term', $term_id, $scoped, $legacy_backup['value']);
273 + }
274 +
275 + /**
276 + * The Yoast taxonomy-meta entry as it is actually stored, without defaults.
277 + *
278 + * `WPSEO_Taxonomy_Meta::get_term_meta()` returns
279 + * `array_merge($defaults_per_term, $stored)`, so all twenty Yoast fields
280 + * are always present and `array_key_exists()` can never tell a field the
281 + * customer set from one they never touched. Yoast only ever stores the
282 + * non-default values -- `validate_term_meta_data()` ends in
283 + * `array_diff_assoc($clean, $defaults_per_term)` -- so the raw option is
284 + * the only place that distinction survives, and it is exactly the
285 + * distinction a restore needs to choose between deleting a key and
286 + * blanking it.
287 + *
288 + * The merged view is still the right thing to build the write from, so
289 + * this is used only to decide what to record as the original.
290 + *
291 + * @param int $term_id Term ID.
292 + * @param string $taxonomy Taxonomy slug.
293 + * @return array Stored entry, empty when the term has none.
294 + */
295 + private static function yoast_stored_tax_meta($term_id, $taxonomy) {
296 + $option = get_option('wpseo_taxonomy_meta', []);
297 +
298 + if (!is_array($option)
299 + || !isset($option[$taxonomy][$term_id])
300 + || !is_array($option[$taxonomy][$term_id])) {
301 + return [];
302 + }
303 +
304 + return $option[$taxonomy][$term_id];
305 + }
306 +
307 + /**
152 308 * Mirror canonical data into Yoast term storage.
153 309 *
154 310 * Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta` option
155 311 * (wp_options), NOT in wp_termmeta. The `WPSEO_Taxonomy_Meta::set_value()`
@@ -157,11 +313,16 @@
157 313 *
158 314 * @param int $term_id Term ID.
159 315 * @param string $taxonomy Taxonomy slug (required by Yoast API).
160 316 * @param array $data Canonical key/value pairs.
161 - * @return bool Always true once dispatch completes.
317 + * @return bool True when the write happened; false when consent is withheld
318 + * or an original could not be preserved.
162 319 */
163 320 private function sync_yoast($term_id, $taxonomy, array $data) {
321 + if (!$this->third_party_writes_allowed()) {
322 + return false;
323 + }
324 +
164 325 if (!class_exists('WPSEO_Taxonomy_Meta')) {
165 326 return false;
166 327 }
167 328
@@ -196,8 +357,49 @@
196 357 $existing = WPSEO_Taxonomy_Meta::get_term_meta($term_id, $taxonomy);
197 358 if (is_array($existing)) {
198 359 $meta_values = array_merge($existing, $meta_values);
199 360 }
361 +
362 + // What Yoast really has on disk, for the backup only. The merged
363 + // view above is the right basis for the write and the wrong one for
364 + // deciding what was there before.
365 + $stored = self::yoast_stored_tax_meta($term_id, $taxonomy);
366 +
367 + // Yoast keeps term SEO in the `wpseo_taxonomy_meta` option, not in
368 + // term meta, so there is no term-meta row to preserve. Save each
369 + // entry we are about to change onto the term itself, keyed by the
370 + // taxonomy and the Yoast field name, so a restore can put the
371 + // option entry back.
372 + //
373 + // The taxonomy belongs in the key because the option is keyed by
374 + // taxonomy first and term ID second: one term ID can hold a
375 + // separate entry under each taxonomy it appears in. Leave it out
376 + // and the first taxonomy synced claims the write-once row, and the
377 + // second taxonomy's original is overwritten with nothing saved.
378 + //
379 + // set_values() rewrites the whole option entry at once, so a single
380 + // unsaved field cannot be skipped in isolation -- if any original
381 + // fails to record, the write is abandoned entirely rather than
382 + // destroying a value with nothing to restore it from.
383 + $originals_saved = class_exists('Metasync_Seo_Backup');
384 + if ($originals_saved) {
385 + foreach ($meta_values as $yoast_key => $yoast_value) {
386 + self::adopt_legacy_yoast_tax_backup($term_id, $taxonomy, $yoast_key);
387 +
388 + $originals_saved = Metasync_Seo_Backup::backup_before_overwrite(
389 + 'term',
390 + $term_id,
391 + self::yoast_tax_backup_field($taxonomy, $yoast_key),
392 + $yoast_value,
393 + array_key_exists($yoast_key, $stored) ? $stored[$yoast_key] : null
394 + ) && $originals_saved;
395 + }
396 + }
397 +
398 + if (!$originals_saved) {
399 + return false;
400 + }
401 +
200 402 WPSEO_Taxonomy_Meta::set_values($term_id, $taxonomy, $meta_values);
201 403
202 404 // Rebuild the Yoast indexable so the frontend and sitemaps render
203 405 // the updated values. Yoast's Indexable_Term_Watcher listens on
@@ -217,28 +419,32 @@
217 419 * @param array $data Canonical key/value pairs.
218 420 * @return bool Always true once dispatch completes.
219 421 */
220 422 private function sync_rankmath($term_id, array $data) {
423 + if (!$this->third_party_writes_allowed()) {
424 + return false;
425 + }
426 +
221 427 if (array_key_exists('title', $data) && $data['title'] !== '') {
222 - update_term_meta($term_id, 'rank_math_title', (string) $data['title']);
428 + $this->write_term_field($term_id, 'rank_math_title', (string) $data['title']);
223 429 }
224 430 if (array_key_exists('desc', $data) && $data['desc'] !== '') {
225 - update_term_meta($term_id, 'rank_math_description', (string) $data['desc']);
431 + $this->write_term_field($term_id, 'rank_math_description', (string) $data['desc']);
226 432 }
227 433 if (array_key_exists('og_title', $data) && $data['og_title'] !== '') {
228 - update_term_meta($term_id, 'rank_math_facebook_title', (string) $data['og_title']);
434 + $this->write_term_field($term_id, 'rank_math_facebook_title', (string) $data['og_title']);
229 435 }
230 436 if (array_key_exists('og_desc', $data) && $data['og_desc'] !== '') {
231 - update_term_meta($term_id, 'rank_math_facebook_description', (string) $data['og_desc']);
437 + $this->write_term_field($term_id, 'rank_math_facebook_description', (string) $data['og_desc']);
232 438 }
233 439 if (array_key_exists('canonical', $data) && $data['canonical'] !== '') {
234 - update_term_meta($term_id, 'rank_math_canonical_url', (string) $data['canonical']);
440 + $this->write_term_field($term_id, 'rank_math_canonical_url', (string) $data['canonical']);
235 441 }
236 442 if (array_key_exists('noindex', $data)) {
237 443 // Rank Math stores robots directives as a serialized PHP array (e.g. ['noindex']).
238 444 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
239 445 $robots = $is_noindex ? ['noindex'] : [];
240 - update_term_meta($term_id, 'rank_math_robots', $robots);
446 + $this->write_term_field($term_id, 'rank_math_robots', $robots);
241 447 }
242 448
243 449 return true;
244 450 }
@@ -243,8 +449,118 @@
243 449 return true;
244 450 }
245 451
246 452 /**
453 + * Preserve the AIOSEO term columns a sync is about to overwrite.
454 + *
455 + * AIOSEO keeps term SEO data in its own `aioseo_terms` table, so there is
456 + * no term-meta row to save and no field a restore could delete. The
457 + * per-column originals and whether the row pre-existed are recorded as term
458 + * meta on the term itself, so a restore can put the original columns back
459 + * without touching the rest of the user's row, and can delete outright a
460 + * row that only exists because we created it.
461 + *
462 + * @param int $term_id Term ID.
463 + * @param string $table Fully prefixed AIOSEO term table name.
464 + * @param array $row Columns and values about to be written.
465 + * @param bool $row_existed Whether AIOSEO already had a row for this term.
466 + * @param array $created Out-param, filled with the backup fields this
467 + * call created, so a failed write can withdraw
468 + * exactly its own rows and no one else's.
469 + * @return bool True when every original was preserved and the caller may write.
470 + */
471 + private function backup_aioseo_columns($term_id, $table, array $row, $row_existed, array &$created) {
472 + $created = [];
473 +
474 + if (!class_exists('Metasync_Seo_Backup')) {
475 + return false;
476 + }
477 +
478 + global $wpdb;
479 +
480 + // Whether the row pre-existed is what a restore uses to choose between
481 + // putting the original columns back and deleting a row that only exists
482 + // because we made it. A marker that will not record is as disqualifying
483 + // as a column that will not.
484 + if (!Metasync_Seo_Backup::record_marker(
485 + 'term',
486 + $term_id,
487 + 'aioseo_row_existed',
488 + $row_existed ? '1' : '0',
489 + $marker_created
490 + )) {
491 + return false;
492 + }
493 +
494 + if ($marker_created) {
495 + $created[] = 'aioseo_row_existed';
496 + }
497 +
498 + $columns = array_diff(array_keys($row), ['updated', 'created', 'term_id']);
499 + if (empty($columns)) {
500 + return true;
501 + }
502 +
503 + $current = null;
504 + if ($row_existed) {
505 + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
506 + // Column list is esc_sql()'d above; the table is a fixed literal.
507 + $current = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas
508 + $wpdb->prepare(
509 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal
510 + "SELECT {$select} FROM {$wpdb->prefix}aioseo_terms WHERE term_id = %d",
511 + $term_id
512 + ),
513 + ARRAY_A
514 + );
515 +
516 + // The row was there a moment ago, so a null answer now is a failed
517 + // read, not an empty row. Recording it as "every column was NULL"
518 + // would tell a later restore to delete values it should put back,
519 + // which is the exact loss this layer exists to prevent. No write is
520 + // happening, so the marker recorded above has to go too — a marker
521 + // left describing a write that never ran is a stale story.
522 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
523 + Metasync_Seo_Backup::discard_backups('term', $term_id, $created);
524 + return false;
525 + }
526 + }
527 +
528 + foreach ($columns as $column) {
529 + // A missing row and a NULL column mean the same thing to a restore:
530 + // there was no value here, so put nothing back.
531 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
532 +
533 + $field = 'aioseo_' . $column;
534 +
535 + // One unsaved column is enough to refuse the whole write: a half-original,
536 + // half-OTTO row is something no restore can unpick. The refusal also
537 + // means the caller writes nothing, so withdraw the marker and the
538 + // columns recorded so far — the same rule as the failed-insert path
539 + // in the caller. Left behind, a row_existed='0' marker would let a
540 + // restore delete a row the customer creates later, and a NULL
541 + // column backup would blank a real value in it.
542 + if (!Metasync_Seo_Backup::backup_before_overwrite(
543 + 'term',
544 + $term_id,
545 + $field,
546 + $row[$column],
547 + $current_value,
548 + $column_created
549 + )) {
550 + Metasync_Seo_Backup::discard_backups('term', $term_id, $created);
551 + return false;
552 + }
553 +
554 + if ($column_created) {
555 + $created[] = $field;
556 + }
557 + }
558 +
559 + return true;
560 + }
561 +
562 + /**
247 563 * Mirror canonical data into the AIOSEO `wp_aioseo_terms` custom table.
248 564 *
249 565 * @param int $term_id Term ID.
250 566 * @param array $data Canonical key/value pairs.
@@ -253,12 +569,16 @@
253 569 */
254 570 private function sync_aioseo($term_id, array $data) {
255 571 global $wpdb;
256 572
573 + if (!$this->third_party_writes_allowed()) {
574 + return false;
575 + }
576 +
257 577 $table = $wpdb->prefix . 'aioseo_terms';
258 578
259 579 // Bail if the AIOSEO term table does not exist (plugin not initialised).
260 - $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
580 + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas
261 581 if ($table_exists !== $table) {
262 582 return false;
263 583 }
264 584
@@ -291,18 +611,39 @@
291 611 }
292 612
293 613 $row['updated'] = current_time('mysql');
294 614
295 - $existing_id = $wpdb->get_var($wpdb->prepare(
296 - "SELECT id FROM {$table} WHERE term_id = %d",
615 + $existing_id = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas
616 + "SELECT id FROM {$wpdb->prefix}aioseo_terms WHERE term_id = %d",
297 617 $term_id
298 618 ));
299 619
620 + // An unreadable probe cannot be treated as "no row". It would commit a
621 + // write-once row_existed='0' for a row AIOSEO really has -- which a
622 + // restore reads as licence to delete it -- and send an INSERT at a row
623 + // that already exists. Leave AIOSEO's row alone and let the next sync
624 + // record the truth.
625 + if (!Metasync_Seo_Backup::db_read_succeeded()) {
626 + return false;
627 + }
628 +
629 + // No original saved means no write. Overwriting anyway is the data loss
630 + // this whole layer exists to prevent.
300 631 if ($existing_id) {
301 - $updated = $wpdb->update($table, $row, ['term_id' => $term_id]);
632 + $backups_created = [];
633 + if (!$this->backup_aioseo_columns($term_id, $table, $row, true, $backups_created)) {
634 + return false;
635 + }
636 +
637 + $updated = $wpdb->update($table, $row, ['term_id' => $term_id]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas
302 638 return $updated !== false;
303 639 }
304 640
641 + $backups_created = [];
642 + if (!$this->backup_aioseo_columns($term_id, $table, $row, false, $backups_created)) {
643 + return false;
644 + }
645 +
305 646 // New row: include term_id, timestamps, and NOT NULL robot defaults.
306 647 $row['term_id'] = $term_id;
307 648 $row['created'] = current_time('mysql');
308 649
@@ -321,8 +662,20 @@
321 662 ];
322 663 // Merge defaults first, then $row on top so our noindex value wins.
323 664 $row = array_merge($robot_defaults, $row);
324 665
325 - $inserted = $wpdb->insert($table, $row);
326 - return $inserted !== false;
666 + $inserted = $wpdb->insert($table, $row); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas
667 +
668 + // The row_existed='0' marker was recorded before the insert, because a
669 + // marker that will not save has to be able to veto the write. A failed
670 + // insert leaves no row of ours, and a marker still claiming one would
671 + // let a restore delete a row the customer creates later. The per-column
672 + // backups beside it go too, or a column captured as NULL for a row that
673 + // never existed would blank a real value the customer later puts in one.
674 + if ($inserted === false) {
675 + Metasync_Seo_Backup::discard_backups('term', $term_id, $backups_created);
676 + return false;
677 + }
678 +
679 + return true;
327 680 }
328 681 }