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
← All changes | includes/class-metasync-plugin-sync.php +570 -54 2.6.23 → 2.7.2 View file →
@@ -61,8 +61,9 @@
61 61 '_metasync_seo_title',
62 62 '_metasync_seo_desc',
63 63 '_metasync_metatitle',
64 64 '_metasync_metadesc',
65 + '_metasync_metakeywords',
65 66 '_metasync_robots_index',
66 67 '_metasync_robots_advanced',
67 68 '_metasync_og_title',
68 69 '_metasync_og_description',
@@ -80,8 +81,9 @@
80 81 '_metasync_otto_og_description',
81 82 '_metasync_otto_twitter_title',
82 83 '_metasync_otto_twitter_description',
83 84 '_metasync_otto_keywords',
85 + '_metasync_otto_canonical',
84 86 ];
85 87
86 88 /**
87 89 * Internal OTTO meta keys mapped to the OTTO Persistence setting that
@@ -102,8 +104,9 @@
102 104 const OTTO_PERSISTENCE_KEYS = [
103 105 '_metasync_otto_title' => 'meta_title',
104 106 '_metasync_otto_description' => 'meta_description',
105 107 '_metasync_otto_keywords' => 'meta_keywords',
108 + '_metasync_metakeywords' => 'meta_keywords',
106 109 '_metasync_otto_og_title' => 'og_title',
107 110 '_metasync_otto_og_description' => 'og_description',
108 111 '_metasync_otto_twitter_title' => 'twitter_title',
109 112 '_metasync_otto_twitter_description' => 'twitter_description',
@@ -253,8 +256,9 @@
253 256 '_metasync_otto_og_description' => 'og_desc',
254 257 '_metasync_otto_twitter_title' => 'twitter_title',
255 258 '_metasync_otto_twitter_description' => 'twitter_desc',
256 259 '_metasync_otto_keywords' => 'focus_keyword',
260 + '_metasync_metakeywords' => 'focus_keyword',
257 261 // Legacy meta box keys → rebuild JSON
258 262 'metasync_common_robots' => '_legacy_robots_to_json',
259 263 'metasync_advance_robots' => '_legacy_robots_to_json',
260 264 ];
@@ -283,8 +287,16 @@
283 287 if ($this->is_yoast_active()) {
284 288 $sync_instance = $this;
285 289 $sync_post_id = (int) $post_id;
286 290 add_action('shutdown', function() use ($sync_instance, $sync_post_id) {
291 + // By shutdown this request has typically seeded the OG defaults
292 + // memo for the post (the sync above reads it); the row write
293 + // has since landed, so drop the memo or the re-sync below
294 + // compares against the pre-save title/excerpt.
295 + // @phpstan-ignore-next-line function.alreadyNarrowedType
296 + if (method_exists('Metasync_OpenGraph', 'clear_default_og_values_memo')) {
297 + Metasync_OpenGraph::clear_default_og_values_memo();
298 + }
287 299 $sync_instance->sync_post($sync_post_id);
288 300 }, 0);
289 301 }
290 302 return;
@@ -377,14 +389,14 @@
377 389 }
378 390 }
379 391 }
380 392
381 - return $fields;
393 + return $this->apply_feature_flags_to_payload($fields);
382 394 }
383 395
384 396 $all_meta = get_post_custom($post_id);
385 397
386 - $get = function ($key) use ($all_meta) {
398 + $get = function ($key) use ($all_meta, $post_id) {
387 399 if (!isset($all_meta[$key])) {
388 400 return '';
389 401 }
390 402 $value = is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
@@ -403,11 +415,38 @@
403 415 if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')
404 416 && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS')
405 417 && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
406 418 ) {
407 - return Metasync_OpenGraph::strip_auto_draft_title($value);
419 + $value = Metasync_OpenGraph::strip_auto_draft_title($value);
408 420 }
409 421
422 + // A social title that is a verbatim snapshot of the post title is the
423 + // old pre-fill, not a customization — mirroring it would hand a
424 + // third-party plugin a copy that a rename leaves stale (and that
425 + // outranks OTTO's fresh value there). Collapsed for the same reason
426 + // as the placeholder above; the chain in $first then falls through
427 + // to OTTO's staging key.
428 + // @phpstan-ignore-next-line function.alreadyNarrowedType
429 + if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot')
430 + && defined('Metasync_OpenGraph::TITLE_DEFAULTED_KEYS')
431 + && in_array($key, Metasync_OpenGraph::TITLE_DEFAULTED_KEYS, true)
432 + ) {
433 + $value = Metasync_OpenGraph::strip_title_snapshot($post_id, $key, $value);
434 + }
435 +
436 + // A social description that is a verbatim snapshot of the resolved
437 + // excerpt is the old pre-fill, not a customization — mirroring it
438 + // would hand a third-party plugin a copy that a later excerpt or
439 + // content edit leaves stale. Collapsed for the same reason as the
440 + // title snapshot above.
441 + // @phpstan-ignore-next-line function.alreadyNarrowedType
442 + if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot')
443 + && defined('Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS')
444 + && in_array($key, Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS, true)
445 + ) {
446 + $value = Metasync_OpenGraph::strip_description_snapshot($post_id, $key, $value);
447 + }
448 +
410 449 return $value;
411 450 };
412 451
413 452 $data = [];
@@ -435,13 +474,20 @@
435 474 }
436 475 return '';
437 476 };
438 477
439 - // title: sidebar > persisted OTTO > volatile OTTO
440 - $data['title'] = $first('_metasync_seo_title', '_metasync_metatitle', '_metasync_otto_title');
478 + // Order comes from Metasync_Seo_Precedence, the one place it is defined.
479 + //
480 + // Imported values come last so a sync triggered by something else still
481 + // reports a title rather than an empty one. They are deliberately NOT in
482 + // WATCHED_KEYS or the on_meta_updated map above: a sync mirrors the
483 + // canonical value into an active third-party plugin's own storage, and
484 + // that plugin renders at a higher effective precedence than OTTO — so
485 + // syncing on an imported write would put the imported title back in
486 + // front of OTTO, which is the exact behaviour this key exists to avoid.
487 + $data['title'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_TITLE));
441 488
442 - // desc: sidebar > persisted OTTO > volatile OTTO
443 - $data['desc'] = $first('_metasync_seo_desc', '_metasync_metadesc', '_metasync_otto_description');
489 + $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION));
444 490
445 491 // Robots directives: check _metasync_robots_advanced JSON first,
446 492 // then fall back to metasync_common_robots array + metasync_advance_robots array
447 493 $robots_json_raw = $get('_metasync_robots_advanced');
@@ -447,9 +493,13 @@
447 493 $robots_json_raw = $get('_metasync_robots_advanced');
448 494 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
449 495
450 496 if (is_array($robots_json)) {
451 - $data['noindex'] = !empty($robots_json['noindex']);
497 + $data['noindex'] = $this->resolve_synced_noindex(
498 + $robots_json,
499 + $get('_metasync_robots_index'),
500 + $get('metasync_common_robots')
501 + );
452 502 $data['nofollow'] = !empty($robots_json['nofollow']);
453 503 $data['noarchive'] = !empty($robots_json['noarchive']);
454 504 $data['nosnippet'] = !empty($robots_json['nosnippet']);
455 505 $data['noimageindex'] = !empty($robots_json['noimageindex']);
@@ -456,11 +506,15 @@
456 506 $data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null);
457 507 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
458 508 $data['max_video_preview'] = isset($robots_json['max_video_preview']) ? (int) $robots_json['max_video_preview'] : (isset($robots_json['max-video-preview']) ? (int) $robots_json['max-video-preview'] : null);
459 509 } else {
460 - // noindex from dedicated key
461 - $robots_index = $get('_metasync_robots_index');
462 - $data['noindex'] = ($robots_index === 'noindex');
510 + // noindex: dedicated key, falling back to the Common Robots checkbox
511 + // array — same sources the front-end emitter honours.
512 + $data['noindex'] = $this->resolve_synced_noindex(
513 + null,
514 + $get('_metasync_robots_index'),
515 + $get('metasync_common_robots')
516 + );
463 517
464 518 // Common robots array (serialized)
465 519 $common_raw = $get('metasync_common_robots');
466 520 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
@@ -509,14 +563,97 @@
509 563 // Canonical, focus keyword, breadcrumb
510 564 // Canonical is validated at the source so a corrupted value ("Array")
511 565 // never propagates into Yoast/RankMath/AIOSEO storage.
512 566 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
513 - $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
567 + // The keyword takes its order from the resolver — customer value, then
568 + // OTTO's volatile tier, then OTTO's persisted tier — so the persistence
569 + // gating in $first() reaches the persisted copy too. Previously the
570 + // persisted copy sat on the customer key, outranking the live one.
571 + $data['focus_keyword'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD));
514 572 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
515 573
574 + return $this->apply_feature_flags_to_payload($data);
575 + }
576 +
577 + /**
578 + * Drop payload keys whose owning feature is switched off in Editor Settings.
579 + *
580 + * A disabled meta box means MetaSync must not interfere with that feature
581 + * anywhere: it emits nothing, suppresses nothing, and — the part this
582 + * guard covers — does not mirror its values into Yoast / Rank Math /
583 + * AIOSEO storage. Without the drop, a sync fired while the feature is off
584 + * resolves the MetaSync value with the feature already stripped and then
585 + * writes that result over the third-party plugin's own value (Rank Math's
586 + * stored noindex replaced by a plain 'index'). The writers are all
587 + * array_key_exists-guarded, so removing a key here skips every write of it.
588 + *
589 + * @param array $data Canonical payload keyed by canonical field name.
590 + * @return array
591 + */
592 + private function apply_feature_flags_to_payload(array $data) {
593 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) {
594 + unset(
595 + $data['noindex'],
596 + $data['nofollow'],
597 + $data['noarchive'],
598 + $data['nosnippet'],
599 + $data['noimageindex']
600 + );
601 + }
602 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::ADVANCE_ROBOTS)) {
603 + unset(
604 + $data['max_snippet'],
605 + $data['max_image_preview'],
606 + $data['max_video_preview']
607 + );
608 + }
609 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
610 + unset(
611 + $data['og_title'],
612 + $data['og_desc'],
613 + $data['og_image'],
614 + $data['twitter_title'],
615 + $data['twitter_desc'],
616 + $data['twitter_card']
617 + );
618 + }
619 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
620 + unset($data['canonical']);
621 + }
622 +
516 623 return $data;
517 624 }
518 625
626 + /**
627 + * Resolve the noindex directive for syncing the way the front-end emitter
628 + * does: any of the three sources saying noindex wins, and none can veto
629 + * another. The JSON rebuilt by sync_legacy_to_json() deliberately omits
630 + * noindex, so reading the JSON alone reported a checkbox-only noindex as
631 + * false — and the writers translated that into an explicit 'index' that
632 + * overwrote a noindex the third-party plugin already held.
633 + *
634 + * @param mixed $robots_json Decoded _metasync_robots_advanced payload (or null).
635 + * @param mixed $robots_index Raw _metasync_robots_index meta value.
636 + * @param mixed $common_raw Raw metasync_common_robots meta value.
637 + * @return bool
638 + */
639 + private function resolve_synced_noindex($robots_json, $robots_index, $common_raw) {
640 + if (is_array($robots_json) && !empty($robots_json['noindex'])) {
641 + return true;
642 + }
643 + if ($robots_index === 'noindex') {
644 + return true;
645 + }
646 + if (!empty($common_raw)) {
647 + $common = maybe_unserialize($common_raw);
648 + if (is_array($common) && !empty($common['noindex'])) {
649 + return true;
650 + }
651 + }
652 +
653 + return false;
654 + }
655 +
519 656 // ------------------------------------------------------------------
520 657 // Plugin detectors
521 658 // ------------------------------------------------------------------
522 659
@@ -566,35 +703,85 @@
566 703 // Per-plugin sync
567 704 // ------------------------------------------------------------------
568 705
569 706 /**
707 + * Whether third-party SEO storage may be written at all.
708 + *
709 + * The site owner's consent switch. Checked once per plugin dispatch rather
710 + * than per field so a sync that is not permitted does no work and, more
711 + * importantly, leaves no half-written row behind.
712 + *
713 + * @return bool
714 + */
715 + private function third_party_writes_allowed() {
716 + return class_exists('Metasync_Seo_Backup') && Metasync_Seo_Backup::is_enabled();
717 + }
718 +
719 + /**
720 + * Write one third-party post-meta field, preserving what it held before.
721 + *
722 + * This bridge runs on `updated_post_meta`, which means it fires from inside
723 + * an OTTO sync's `_metasync_otto_*` write — before the same sync reaches its
724 + * own direct Rank Math / Yoast writes further down. It is therefore usually
725 + * the first code to touch the customer's value, and the backup it takes here
726 + * is the one that holds the true original.
727 + *
728 + * @param int $post_id Post ID.
729 + * @param string $key Third-party meta key.
730 + * @param mixed $value Value to write.
731 + * @return bool True when the write happened.
732 + */
733 + private function write_post_field($post_id, $key, $value) {
734 + return class_exists('Metasync_Seo_Backup')
735 + && Metasync_Seo_Backup::write_post_meta($post_id, $key, $value);
736 + }
737 +
738 + /**
570 739 * Mirror canonical data into Yoast post meta and indexable cache.
571 740 *
572 741 * @param int $post_id Post ID.
573 742 * @param array $data Canonical key/value pairs.
574 - * @return bool True once dispatch completes.
743 + * @return bool True when at least one value-bearing field was mirrored,
744 + * false when the payload carried nothing to write. The caller
745 + * stamps `_metasync_plugin_sync_ts` from this, so it must
746 + * describe what was written and not merely that dispatch was
747 + * reached.
748 + *
749 + * Robots directives are excluded from that answer on purpose.
750 + * collect_post_data() synthesises noindex/nofollow on every
751 + * payload — absent any stored value they resolve to plain
752 + * index/follow — so they are written on every sync and say
753 + * nothing about whether a canonical value existed to mirror.
754 + * Counting them would make the result unconditionally true,
755 + * which is the bug this return contract exists to fix.
575 756 */
576 757 private function sync_yoast($post_id, array $data) {
758 + if (!$this->third_party_writes_allowed()) {
759 + return false;
760 + }
761 +
762 + $wrote = false;
763 +
577 764 // title
578 765 if (!empty($data['title'])) {
579 - update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
766 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_title', (string) $data['title']);
580 767 }
581 768
582 769 // description -- strip newlines first
583 770 if (!empty($data['desc'])) {
584 771 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
585 - update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
772 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
586 773 }
587 774
588 775 // noindex: '0'=default, '1'=noindex, '2'=index
589 776 if (array_key_exists('noindex', $data)) {
590 777 $val = $data['noindex'] ? '1' : '2';
591 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
778 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
592 779 }
593 780
594 781 // nofollow: '0'=follow, '1'=nofollow
595 782 if (array_key_exists('nofollow', $data)) {
596 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
783 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
597 784 }
598 785
599 786 // advanced robots: comma-separated NO spaces
600 787 $adv = [];
@@ -606,38 +793,41 @@
606 793 }
607 794 if (!empty($data['noimageindex'])) {
608 795 $adv[] = 'noimageindex';
609 796 }
610 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
797 + // Written unconditionally so clearing the last directive clears the
798 + // field. Like the other robots writes it carries no canonical value,
799 + // so it does not make this a successful sync.
800 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
611 801
612 802 // OG
613 803 if (!empty($data['og_title'])) {
614 - update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
804 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']) || $wrote;
615 805 }
616 806 if (!empty($data['og_desc'])) {
617 - update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
807 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']) || $wrote;
618 808 }
619 809 if (!empty($data['og_image'])) {
620 - update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
810 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])) || $wrote;
621 811 }
622 812
623 813 // Twitter
624 814 if (!empty($data['twitter_title'])) {
625 - update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
815 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']) || $wrote;
626 816 }
627 817 if (!empty($data['twitter_desc'])) {
628 - update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
818 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']) || $wrote;
629 819 }
630 820
631 821 // Canonical, focus keyword, breadcrumb
632 822 if (!empty($data['canonical'])) {
633 - update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
823 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])) || $wrote;
634 824 }
635 825 if (!empty($data['focus_keyword'])) {
636 - update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
826 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']) || $wrote;
637 827 }
638 828 if (!empty($data['breadcrumb_title'])) {
639 - update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
829 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']) || $wrote;
640 830 }
641 831
642 832 // Update wp_yoast_indexable cache row for immediate effect
643 833 global $wpdb;
@@ -675,9 +865,9 @@
675 865 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
676 866 }
677 867 if (!empty($data['twitter_card'])) {
678 868 // twitter_card column only exists in newer Yoast versions; skip if absent
679 - $col_check = $wpdb->get_var($wpdb->prepare(
869 + $col_check = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
680 870 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
681 871 DB_NAME,
682 872 $indexable_table
683 873 ));
@@ -695,15 +885,38 @@
695 885 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
696 886 }
697 887
698 888 if (!empty($updates)) {
699 - $row_exists = $wpdb->get_var($wpdb->prepare(
700 - "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
889 + $row_exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
890 + "SELECT id FROM {$wpdb->prefix}yoast_indexable WHERE object_id = %d AND object_type = 'post'",
701 891 $post_id
702 892 ));
703 893
894 + // This probe decides two things at once: the value of the write-once
895 + // row_existed marker, and whether the write below is an UPDATE or an
896 + // INSERT. A failed probe answers "no row" for a row that is really
897 + // there, which commits a '0' marker telling a restore to delete the
898 + // customer's row and fires an INSERT against a row that already
899 + // exists. Neither is correctable afterwards, so an unreadable probe
900 + // has to abandon the indexable write entirely.
901 + //
902 + // The post meta above has already landed, so the receipt still
903 + // reports it. Only the indexable is skipped.
904 + if (!Metasync_Seo_Backup::db_read_succeeded()) {
905 + return $wrote;
906 + }
907 +
908 + // No original saved means no write, exactly as on the AIOSEO table.
909 + // This row is what Yoast actually renders from, so a restore that
910 + // cannot reach it puts the meta back and leaves the customer's live
911 + // pages still showing OTTO's values.
912 + $backups_created = [];
913 + if (!$this->backup_yoast_indexable_columns($post_id, $indexable_table, $updates, (bool) $row_exists, $backups_created)) {
914 + return $wrote;
915 + }
916 +
704 917 if ($row_exists) {
705 - $wpdb->update(
918 + $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
706 919 $indexable_table,
707 920 $updates,
708 921 ['object_id' => $post_id, 'object_type' => 'post']
709 922 );
@@ -723,12 +936,139 @@
723 936 'is_cornerstone' => 0,
724 937 'created_at' => current_time('mysql'),
725 938 'updated_at' => current_time('mysql'),
726 939 ], $updates);
727 - $wpdb->insert($indexable_table, $insert);
940 +
941 + // The row_existed='0' marker was recorded before this insert,
942 + // because a marker that will not save has to be able to veto the
943 + // write. A failed insert means no row of ours exists, and a
944 + // marker left saying otherwise would let a restore delete a row
945 + // Yoast or the customer creates afterwards. The per-column
946 + // backups recorded beside it go too: they are write-once, so a
947 + // column captured as NULL because there was no row would stay
948 + // NULL for good and blank a real value the customer later puts
949 + // in that row.
950 + //
951 + // The post meta above has already been written, so the return
952 + // still reports what landed. Claiming nothing was written would
953 + // tell the conflict handler this post is unsynced to Yoast while
954 + // Yoast's own meta holds our values, and it would hand tag
955 + // ownership to the wrong plugin.
956 + if ($wpdb->insert($indexable_table, $insert) === false) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
957 + Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
958 + return $wrote;
959 + }
728 960 }
729 961 }
730 962
963 + return $wrote;
964 + }
965 +
966 + /**
967 + * Preserve the wp_yoast_indexable columns this sync is about to overwrite.
968 + *
969 + * Yoast serves the frontend from this table, not from post meta, so the
970 + * meta backups taken by write_post_field() do not by themselves make the
971 + * change reversible. Same shape as backup_aioseo_columns():
972 + *
973 + * - one backup per column being written, so a restore can put the originals
974 + * back and leave every other column of the user's row alone;
975 + * - whether the row existed at all, so a restore can delete a row that only
976 + * exists because we created it instead of leaving an empty shell behind.
977 + *
978 + * @param int $post_id Post ID.
979 + * @param string $table Fully prefixed indexable table name.
980 + * @param array $updates Columns and values about to be written.
981 + * @param bool $row_existed Whether Yoast already had a row for this post.
982 + * @param array $created Out-param, filled with the backup fields this
983 + * call created, so a failed write can withdraw
984 + * exactly its own rows and no one else's.
985 + * @return bool True when every original was preserved and the caller may write.
986 + */
987 + private function backup_yoast_indexable_columns($post_id, $table, array $updates, $row_existed, array &$created) {
988 + $created = [];
989 +
990 + if (!class_exists('Metasync_Seo_Backup')) {
991 + return false;
992 + }
993 +
994 + global $wpdb;
995 +
996 + if (!Metasync_Seo_Backup::record_marker(
997 + 'post',
998 + $post_id,
999 + 'yoast_indexable_row_existed',
1000 + $row_existed ? '1' : '0',
1001 + $marker_created
1002 + )) {
1003 + return false;
1004 + }
1005 +
1006 + if ($marker_created) {
1007 + $created[] = 'yoast_indexable_row_existed';
1008 + }
1009 +
1010 + $columns = array_keys($updates);
1011 + if (empty($columns)) {
1012 + return true;
1013 + }
1014 +
1015 + $current = null;
1016 + if ($row_existed) {
1017 + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1018 + // Column list is esc_sql()'d above; the table is a fixed literal.
1019 + $current = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1020 + $wpdb->prepare(
1021 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal
1022 + "SELECT {$select} FROM {$wpdb->prefix}yoast_indexable WHERE object_id = %d AND object_type = 'post'",
1023 + $post_id
1024 + ),
1025 + ARRAY_A
1026 + );
1027 +
1028 + // The row was there a moment ago, so a null answer now is a failed
1029 + // read, not an empty row. Recording it as "every column was NULL"
1030 + // would tell a later restore to delete values it should put back,
1031 + // which is the exact loss this layer exists to prevent. No write is
1032 + // happening, so the marker recorded above has to go too — a marker
1033 + // left describing a write that never ran is a stale story.
1034 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1035 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1036 + return false;
1037 + }
1038 + }
1039 +
1040 + foreach ($columns as $column) {
1041 + // A missing row and a NULL column are the same thing to a restore:
1042 + // there was no value here, so put nothing back.
1043 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1044 +
1045 + $field = 'yoast_indexable_' . $column;
1046 +
1047 + // One unsaved column is enough to refuse the whole write: a
1048 + // half-original, half-OTTO row is something no restore can unpick.
1049 + // The refusal also means the caller writes nothing, so withdraw the
1050 + // marker and the columns recorded so far — the same rule as the
1051 + // failed-insert path in sync_yoast(). Left behind, a row_existed='0'
1052 + // marker would let a restore delete a row the customer creates
1053 + // later, and a NULL column backup would blank a real value in it.
1054 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1055 + 'post',
1056 + $post_id,
1057 + $field,
1058 + $updates[$column],
1059 + $current_value,
1060 + $column_created
1061 + )) {
1062 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1063 + return false;
1064 + }
1065 +
1066 + if ($column_created) {
1067 + $created[] = $field;
1068 + }
1069 + }
1070 +
731 1071 return true;
732 1072 }
733 1073
734 1074 /**
@@ -735,17 +1075,25 @@
735 1075 * Mirror canonical data into Rank Math post meta.
736 1076 *
737 1077 * @param int $post_id Post ID.
738 1078 * @param array $data Canonical key/value pairs.
739 - * @return bool True once dispatch completes.
1079 + * @return bool True when at least one value-bearing field was mirrored,
1080 + * false when the payload carried nothing to write. Robots
1081 + * directives are excluded for the reason given on sync_yoast().
740 1082 */
741 1083 private function sync_rankmath($post_id, array $data) {
1084 + if (!$this->third_party_writes_allowed()) {
1085 + return false;
1086 + }
1087 +
1088 + $wrote = false;
1089 +
742 1090 // title, desc
743 1091 if (!empty($data['title'])) {
744 - update_post_meta($post_id, 'rank_math_title', $data['title']);
1092 + $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
745 1093 }
746 1094 if (!empty($data['desc'])) {
747 - update_post_meta($post_id, 'rank_math_description', $data['desc']);
1095 + $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
748 1096 }
749 1097
750 1098 // robots: PHP indexed array
751 1099 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
@@ -757,9 +1105,9 @@
757 1105 }
758 1106 if (array_key_exists('nofollow', $data)) {
759 1107 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
760 1108 }
761 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1109 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
762 1110 }
763 1111
764 1112 // Advanced robots: max-* go into rank_math_advanced_robots
765 1113 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
@@ -787,9 +1135,9 @@
787 1135 $val = (int) $data['max_video_preview'];
788 1136 $adv['max-video-preview'] = (string) $val;
789 1137 }
790 1138 if (!empty($adv)) {
791 - update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
1139 + $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
792 1140 }
793 1141 }
794 1142
795 1143 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
@@ -804,51 +1152,164 @@
804 1152 if (!empty($data[$dir])) {
805 1153 $robots[] = $dir;
806 1154 }
807 1155 }
808 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1156 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
809 1157 }
810 1158
811 1159 // OG
812 1160 if (!empty($data['og_title'])) {
813 - update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
1161 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
814 1162 }
815 1163 if (!empty($data['og_desc'])) {
816 - update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
1164 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
817 1165 }
818 1166 if (!empty($data['og_image'])) {
819 - update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
1167 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])) || $wrote;
820 1168 $img_id = attachment_url_to_postid($data['og_image']);
821 1169 if ($img_id) {
822 - update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
1170 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image_id', $img_id) || $wrote;
823 1171 }
824 1172 }
825 1173
826 1174 // Twitter
827 1175 if (!empty($data['twitter_title'])) {
828 - update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
1176 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
829 1177 }
830 1178 if (!empty($data['twitter_desc'])) {
831 - update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
1179 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
832 1180 }
833 1181 if (!empty($data['twitter_card'])) {
834 1182 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
835 1183 if (in_array($data['twitter_card'], $valid_cards, true)) {
836 - update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
1184 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
837 1185 }
838 1186 }
839 1187
840 1188 // Canonical, focus keyword, breadcrumb
841 1189 if (!empty($data['canonical'])) {
842 - update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
1190 + $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
843 1191 }
844 1192 if (!empty($data['focus_keyword'])) {
845 - update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
1193 + $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
846 1194 }
847 1195 if (!empty($data['breadcrumb_title'])) {
848 - update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
1196 + $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
849 1197 }
850 1198
1199 + return $wrote;
1200 + }
1201 +
1202 + /**
1203 + * Preserve the AIOSEO columns a sync is about to overwrite.
1204 + *
1205 + * AIOSEO keeps post SEO data in its own `aioseo_posts` table rather than in
1206 + * post meta, so there is no meta row to save and no field a restore could
1207 + * delete. Two things are recorded instead, both as post meta on the post
1208 + * itself:
1209 + *
1210 + * - one backup per column being written, so a restore can put the original
1211 + * values back and leave every other column of the user's row alone;
1212 + * - whether the row existed at all, so a restore can delete a row that only
1213 + * exists because we created it instead of leaving an empty shell behind.
1214 + *
1215 + * @param int $post_id Post ID.
1216 + * @param string $table Fully prefixed AIOSEO table name.
1217 + * @param array $row Columns and values about to be written.
1218 + * @param bool $row_existed Whether AIOSEO already had a row for this post.
1219 + * @param array $created Out-param, filled with the backup fields this
1220 + * call created, so a failed write can withdraw
1221 + * exactly its own rows and no one else's.
1222 + * @return bool True when every original was preserved and the caller may write.
1223 + */
1224 + private function backup_aioseo_columns($post_id, $table, array $row, $row_existed, array &$created) {
1225 + $created = [];
1226 +
1227 + if (!class_exists('Metasync_Seo_Backup')) {
1228 + return false;
1229 + }
1230 +
1231 + global $wpdb;
1232 +
1233 + // Whether the row pre-existed is what a restore uses to choose between
1234 + // putting the original columns back and deleting a row that only exists
1235 + // because we made it. A marker that will not record is as disqualifying
1236 + // as a column that will not.
1237 + if (!Metasync_Seo_Backup::record_marker(
1238 + 'post',
1239 + $post_id,
1240 + 'aioseo_row_existed',
1241 + $row_existed ? '1' : '0',
1242 + $marker_created
1243 + )) {
1244 + return false;
1245 + }
1246 +
1247 + if ($marker_created) {
1248 + $created[] = 'aioseo_row_existed';
1249 + }
1250 +
1251 + $columns = array_diff(array_keys($row), ['updated', 'created', 'post_id']);
1252 + if (empty($columns)) {
1253 + return true;
1254 + }
1255 +
1256 + $current = null;
1257 + if ($row_existed) {
1258 + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1259 + // Column list is esc_sql()'d above; the table is a fixed literal.
1260 + $current = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1261 + $wpdb->prepare(
1262 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal
1263 + "SELECT {$select} FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d",
1264 + $post_id
1265 + ),
1266 + ARRAY_A
1267 + );
1268 +
1269 + // The row was there a moment ago, so a null answer now is a failed
1270 + // read, not an empty row. Recording it as "every column was NULL"
1271 + // would tell a later restore to delete values it should put back,
1272 + // which is the exact loss this layer exists to prevent. No write is
1273 + // happening, so the marker recorded above has to go too — a marker
1274 + // left describing a write that never ran is a stale story.
1275 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1276 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1277 + return false;
1278 + }
1279 + }
1280 +
1281 + foreach ($columns as $column) {
1282 + // A missing row and a NULL column are the same thing to a restore:
1283 + // there was no value here, so put nothing back.
1284 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1285 +
1286 + $field = 'aioseo_' . $column;
1287 +
1288 + // One unsaved column is enough to refuse the whole write: a half-original,
1289 + // half-OTTO row is something no restore can unpick. The refusal also
1290 + // means the caller writes nothing, so withdraw the marker and the
1291 + // columns recorded so far — the same rule as the failed-insert path
1292 + // in sync_aioseo(). Left behind, a row_existed='0' marker would let
1293 + // a restore delete a row the customer creates later, and a NULL
1294 + // column backup would blank a real value in it.
1295 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1296 + 'post',
1297 + $post_id,
1298 + $field,
1299 + $row[$column],
1300 + $current_value,
1301 + $column_created
1302 + )) {
1303 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1304 + return false;
1305 + }
1306 +
1307 + if ($column_created) {
1308 + $created[] = $field;
1309 + }
1310 + }
1311 +
851 1312 return true;
852 1313 }
853 1314
854 1315 /**
@@ -855,54 +1316,73 @@
855 1316 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
856 1317 *
857 1318 * @param int $post_id Post ID.
858 1319 * @param array $data Canonical key/value pairs.
859 - * @return bool True when the row was written, false when the table is
860 - * missing or the write failed.
1320 + * @return bool True when at least one value-bearing field was written,
1321 + * false when the table is missing, the write failed, or the
1322 + * payload carried only robots directives. Robots are excluded
1323 + * for the reason given on sync_yoast(), so the receipt means
1324 + * the same thing for all three plugins.
861 1325 */
862 1326 private function sync_aioseo($post_id, array $data) {
863 1327 global $wpdb;
864 1328
1329 + if (!$this->third_party_writes_allowed()) {
1330 + return false;
1331 + }
1332 +
865 1333 $table = $wpdb->prefix . 'aioseo_posts';
866 1334
867 1335 // Bail if the AIOSEO post table does not exist (plugin not initialised).
868 - $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
1336 + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
869 1337 if ($table_exists !== $table) {
870 1338 return false;
871 1339 }
872 1340
873 1341 $row = [];
1342 + // Robots fill $row too, so a separate flag is needed: the row is still
1343 + // written for a robots-only payload, it just is not a content sync.
1344 + $wrote = false;
874 1345
875 1346 if (!empty($data['title'])) {
876 1347 $row['title'] = sanitize_text_field($data['title']);
1348 + $wrote = true;
877 1349 }
878 1350 if (!empty($data['desc'])) {
879 1351 $row['description'] = sanitize_text_field($data['desc']);
1352 + $wrote = true;
880 1353 }
881 1354 if (!empty($data['og_title'])) {
882 1355 $row['og_title'] = sanitize_text_field($data['og_title']);
1356 + $wrote = true;
883 1357 }
884 1358 if (!empty($data['og_desc'])) {
885 1359 $row['og_description'] = sanitize_text_field($data['og_desc']);
1360 + $wrote = true;
886 1361 }
887 1362 if (!empty($data['og_image'])) {
888 1363 $row['og_image_type'] = 'custom';
889 1364 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
1365 + $wrote = true;
890 1366 }
891 1367 if (!empty($data['twitter_title'])) {
892 1368 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
1369 + $wrote = true;
893 1370 }
894 1371 if (!empty($data['twitter_desc'])) {
895 1372 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
1373 + $wrote = true;
896 1374 }
897 1375 if (!empty($data['twitter_card'])) {
898 1376 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
899 1377 if (in_array($data['twitter_card'], $valid_cards, true)) {
900 1378 $row['twitter_card'] = $data['twitter_card'];
1379 + $wrote = true;
901 1380 }
902 1381 }
903 1382 if (!empty($data['canonical'])) {
904 1383 $row['canonical_url'] = esc_url_raw($data['canonical']);
1384 + $wrote = true;
905 1385 }
906 1386
907 1387 // focus keyword as keyphrases JSON
908 1388 if (!empty($data['focus_keyword'])) {
@@ -913,8 +1393,9 @@
913 1393 'analysis' => new \stdClass(),
914 1394 ],
915 1395 'additional' => [],
916 1396 ]);
1397 + $wrote = true;
917 1398 }
918 1399
919 1400 // Robots
920 1401 $has_robots = false;
@@ -960,17 +1441,38 @@
960 1441 }
961 1442
962 1443 $row['updated'] = current_time('mysql');
963 1444
964 - $existing_id = $wpdb->get_var($wpdb->prepare(
965 - "SELECT id FROM {$table} WHERE post_id = %d",
1445 + $existing_id = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1446 + "SELECT id FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d",
966 1447 $post_id
967 1448 ));
968 1449
1450 + // An unreadable probe cannot be treated as "no row". It would commit a
1451 + // write-once row_existed='0' for a row AIOSEO really has -- which a
1452 + // restore reads as licence to delete it -- and send an INSERT at a row
1453 + // that already exists. Leave AIOSEO's row alone and let the next sync
1454 + // record the truth.
1455 + if (!Metasync_Seo_Backup::db_read_succeeded()) {
1456 + return false;
1457 + }
1458 +
1459 + // No original saved means no write. Overwriting anyway is the data loss
1460 + // this whole layer exists to prevent.
969 1461 if ($existing_id) {
970 - return $wpdb->update($table, $row, ['post_id' => $post_id]) !== false;
1462 + $backups_created = [];
1463 + if (!$this->backup_aioseo_columns($post_id, $table, $row, true, $backups_created)) {
1464 + return false;
1465 + }
1466 +
1467 + return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
971 1468 }
972 1469
1470 + $backups_created = [];
1471 + if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1472 + return false;
1473 + }
1474 +
973 1475 // New row -- must include all NOT NULL columns with no defaults
974 1476 $row['post_id'] = $post_id;
975 1477 $row['created'] = current_time('mysql');
976 1478 $robot_defaults = [
@@ -984,9 +1486,23 @@
984 1486 'robots_notranslate' => 0,
985 1487 ];
986 1488 $row = array_merge($robot_defaults, $row);
987 1489
988 - return $wpdb->insert($table, $row) !== false;
1490 + $inserted = $wpdb->insert($table, $row); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1491 +
1492 + // The row_existed='0' marker was recorded before the insert, because a
1493 + // marker that will not save has to be able to veto the write. If the
1494 + // insert then failed there is no row of ours, and leaving the marker
1495 + // behind would let a restore delete a row the customer creates later.
1496 + // The per-column backups beside it go too, or a column captured as NULL
1497 + // for a row that never existed would blank a real value the customer
1498 + // later puts in one.
1499 + if ($inserted === false) {
1500 + Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
1501 + return false;
1502 + }
1503 +
1504 + return $wrote;
989 1505 }
990 1506
991 1507 // ------------------------------------------------------------------
992 1508 // Two-way sync: sidebar JSON ↔ legacy meta boxes