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 +377 -14 2.6.3 → 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,37 @@
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 +
115 + // Never mirror a corrupted or non-URL canonical into third-party
116 + // storage — a nested-array value cast with (string) becomes the
117 + // literal "Array" and propagates into Yoast/RankMath/AIOSEO.
118 + if (array_key_exists('canonical', $data)) {
119 + $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($data['canonical']);
120 + if ($data['canonical'] === '') {
121 + unset($data['canonical']);
122 + }
123 + }
124 +
73 125 if ($this->is_yoast_active()) {
74 126 $results['yoast'] = $this->sync_yoast((int) $term_id, $taxonomy, $data);
75 127 }
76 128
@@ -138,8 +190,122 @@
138 190 // Per-plugin sync
139 191 // ------------------------------------------------------------------
140 192
141 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 + /**
142 308 * Mirror canonical data into Yoast term storage.
143 309 *
144 310 * Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta` option
145 311 * (wp_options), NOT in wp_termmeta. The `WPSEO_Taxonomy_Meta::set_value()`
@@ -147,11 +313,16 @@
147 313 *
148 314 * @param int $term_id Term ID.
149 315 * @param string $taxonomy Taxonomy slug (required by Yoast API).
150 316 * @param array $data Canonical key/value pairs.
151 - * @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.
152 319 */
153 320 private function sync_yoast($term_id, $taxonomy, array $data) {
321 + if (!$this->third_party_writes_allowed()) {
322 + return false;
323 + }
324 +
154 325 if (!class_exists('WPSEO_Taxonomy_Meta')) {
155 326 return false;
156 327 }
157 328
@@ -186,8 +357,49 @@
186 357 $existing = WPSEO_Taxonomy_Meta::get_term_meta($term_id, $taxonomy);
187 358 if (is_array($existing)) {
188 359 $meta_values = array_merge($existing, $meta_values);
189 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 +
190 402 WPSEO_Taxonomy_Meta::set_values($term_id, $taxonomy, $meta_values);
191 403
192 404 // Rebuild the Yoast indexable so the frontend and sitemaps render
193 405 // the updated values. Yoast's Indexable_Term_Watcher listens on
@@ -207,28 +419,32 @@
207 419 * @param array $data Canonical key/value pairs.
208 420 * @return bool Always true once dispatch completes.
209 421 */
210 422 private function sync_rankmath($term_id, array $data) {
423 + if (!$this->third_party_writes_allowed()) {
424 + return false;
425 + }
426 +
211 427 if (array_key_exists('title', $data) && $data['title'] !== '') {
212 - update_term_meta($term_id, 'rank_math_title', (string) $data['title']);
428 + $this->write_term_field($term_id, 'rank_math_title', (string) $data['title']);
213 429 }
214 430 if (array_key_exists('desc', $data) && $data['desc'] !== '') {
215 - update_term_meta($term_id, 'rank_math_description', (string) $data['desc']);
431 + $this->write_term_field($term_id, 'rank_math_description', (string) $data['desc']);
216 432 }
217 433 if (array_key_exists('og_title', $data) && $data['og_title'] !== '') {
218 - 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']);
219 435 }
220 436 if (array_key_exists('og_desc', $data) && $data['og_desc'] !== '') {
221 - 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']);
222 438 }
223 439 if (array_key_exists('canonical', $data) && $data['canonical'] !== '') {
224 - 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']);
225 441 }
226 442 if (array_key_exists('noindex', $data)) {
227 443 // Rank Math stores robots directives as a serialized PHP array (e.g. ['noindex']).
228 444 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
229 445 $robots = $is_noindex ? ['noindex'] : [];
230 - update_term_meta($term_id, 'rank_math_robots', $robots);
446 + $this->write_term_field($term_id, 'rank_math_robots', $robots);
231 447 }
232 448
233 449 return true;
234 450 }
@@ -233,8 +449,118 @@
233 449 return true;
234 450 }
235 451
236 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 + /**
237 563 * Mirror canonical data into the AIOSEO `wp_aioseo_terms` custom table.
238 564 *
239 565 * @param int $term_id Term ID.
240 566 * @param array $data Canonical key/value pairs.
@@ -243,12 +569,16 @@
243 569 */
244 570 private function sync_aioseo($term_id, array $data) {
245 571 global $wpdb;
246 572
573 + if (!$this->third_party_writes_allowed()) {
574 + return false;
575 + }
576 +
247 577 $table = $wpdb->prefix . 'aioseo_terms';
248 578
249 579 // Bail if the AIOSEO term table does not exist (plugin not initialised).
250 - $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
251 581 if ($table_exists !== $table) {
252 582 return false;
253 583 }
254 584
@@ -281,18 +611,39 @@
281 611 }
282 612
283 613 $row['updated'] = current_time('mysql');
284 614
285 - $existing_id = $wpdb->get_var($wpdb->prepare(
286 - "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",
287 617 $term_id
288 618 ));
289 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.
290 631 if ($existing_id) {
291 - $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
292 638 return $updated !== false;
293 639 }
294 640
641 + $backups_created = [];
642 + if (!$this->backup_aioseo_columns($term_id, $table, $row, false, $backups_created)) {
643 + return false;
644 + }
645 +
295 646 // New row: include term_id, timestamps, and NOT NULL robot defaults.
296 647 $row['term_id'] = $term_id;
297 648 $row['created'] = current_time('mysql');
298 649
@@ -311,8 +662,20 @@
311 662 ];
312 663 // Merge defaults first, then $row on top so our noindex value wins.
313 664 $row = array_merge($robot_defaults, $row);
314 665
315 - $inserted = $wpdb->insert($table, $row);
316 - 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;
317 680 }
318 681 }