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 +389 -60 2.6.26 → 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 ];
@@ -559,9 +563,13 @@
559 563 // Canonical, focus keyword, breadcrumb
560 564 // Canonical is validated at the source so a corrupted value ("Array")
561 565 // never propagates into Yoast/RankMath/AIOSEO storage.
562 566 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
563 - $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));
564 572 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
565 573
566 574 return $this->apply_feature_flags_to_payload($data);
567 575 }
@@ -695,8 +703,40 @@
695 703 // Per-plugin sync
696 704 // ------------------------------------------------------------------
697 705
698 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 + /**
699 739 * Mirror canonical data into Yoast post meta and indexable cache.
700 740 *
701 741 * @param int $post_id Post ID.
702 742 * @param array $data Canonical key/value pairs.
@@ -714,32 +754,34 @@
714 754 * Counting them would make the result unconditionally true,
715 755 * which is the bug this return contract exists to fix.
716 756 */
717 757 private function sync_yoast($post_id, array $data) {
758 + if (!$this->third_party_writes_allowed()) {
759 + return false;
760 + }
761 +
718 762 $wrote = false;
719 763
720 764 // title
721 765 if (!empty($data['title'])) {
722 - update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
723 - $wrote = true;
766 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_title', (string) $data['title']);
724 767 }
725 768
726 769 // description -- strip newlines first
727 770 if (!empty($data['desc'])) {
728 771 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
729 - update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
730 - $wrote = true;
772 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
731 773 }
732 774
733 775 // noindex: '0'=default, '1'=noindex, '2'=index
734 776 if (array_key_exists('noindex', $data)) {
735 777 $val = $data['noindex'] ? '1' : '2';
736 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
778 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
737 779 }
738 780
739 781 // nofollow: '0'=follow, '1'=nofollow
740 782 if (array_key_exists('nofollow', $data)) {
741 - 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');
742 784 }
743 785
744 786 // advanced robots: comma-separated NO spaces
745 787 $adv = [];
@@ -754,46 +796,38 @@
754 796 }
755 797 // Written unconditionally so clearing the last directive clears the
756 798 // field. Like the other robots writes it carries no canonical value,
757 799 // so it does not make this a successful sync.
758 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
800 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
759 801
760 802 // OG
761 803 if (!empty($data['og_title'])) {
762 - update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
763 - $wrote = true;
804 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']) || $wrote;
764 805 }
765 806 if (!empty($data['og_desc'])) {
766 - update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
767 - $wrote = true;
807 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']) || $wrote;
768 808 }
769 809 if (!empty($data['og_image'])) {
770 - update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
771 - $wrote = true;
810 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])) || $wrote;
772 811 }
773 812
774 813 // Twitter
775 814 if (!empty($data['twitter_title'])) {
776 - update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
777 - $wrote = true;
815 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']) || $wrote;
778 816 }
779 817 if (!empty($data['twitter_desc'])) {
780 - update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
781 - $wrote = true;
818 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']) || $wrote;
782 819 }
783 820
784 821 // Canonical, focus keyword, breadcrumb
785 822 if (!empty($data['canonical'])) {
786 - update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
787 - $wrote = true;
823 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])) || $wrote;
788 824 }
789 825 if (!empty($data['focus_keyword'])) {
790 - update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
791 - $wrote = true;
826 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']) || $wrote;
792 827 }
793 828 if (!empty($data['breadcrumb_title'])) {
794 - update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
795 - $wrote = true;
829 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']) || $wrote;
796 830 }
797 831
798 832 // Update wp_yoast_indexable cache row for immediate effect
799 833 global $wpdb;
@@ -831,9 +865,9 @@
831 865 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
832 866 }
833 867 if (!empty($data['twitter_card'])) {
834 868 // twitter_card column only exists in newer Yoast versions; skip if absent
835 - $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
836 870 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
837 871 DB_NAME,
838 872 $indexable_table
839 873 ));
@@ -851,15 +885,38 @@
851 885 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
852 886 }
853 887
854 888 if (!empty($updates)) {
855 - $row_exists = $wpdb->get_var($wpdb->prepare(
856 - "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'",
857 891 $post_id
858 892 ));
859 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 +
860 917 if ($row_exists) {
861 - $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
862 919 $indexable_table,
863 920 $updates,
864 921 ['object_id' => $post_id, 'object_type' => 'post']
865 922 );
@@ -879,9 +936,28 @@
879 936 'is_cornerstone' => 0,
880 937 'created_at' => current_time('mysql'),
881 938 'updated_at' => current_time('mysql'),
882 939 ], $updates);
883 - $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 + }
884 960 }
885 961 }
886 962
887 963 return $wrote;
@@ -887,8 +963,116 @@
887 963 return $wrote;
888 964 }
889 965
890 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 +
1071 + return true;
1072 + }
1073 +
1074 + /**
891 1075 * Mirror canonical data into Rank Math post meta.
892 1076 *
893 1077 * @param int $post_id Post ID.
894 1078 * @param array $data Canonical key/value pairs.
@@ -896,18 +1080,20 @@
896 1080 * false when the payload carried nothing to write. Robots
897 1081 * directives are excluded for the reason given on sync_yoast().
898 1082 */
899 1083 private function sync_rankmath($post_id, array $data) {
1084 + if (!$this->third_party_writes_allowed()) {
1085 + return false;
1086 + }
1087 +
900 1088 $wrote = false;
901 1089
902 1090 // title, desc
903 1091 if (!empty($data['title'])) {
904 - update_post_meta($post_id, 'rank_math_title', $data['title']);
905 - $wrote = true;
1092 + $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
906 1093 }
907 1094 if (!empty($data['desc'])) {
908 - update_post_meta($post_id, 'rank_math_description', $data['desc']);
909 - $wrote = true;
1095 + $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
910 1096 }
911 1097
912 1098 // robots: PHP indexed array
913 1099 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
@@ -919,9 +1105,9 @@
919 1105 }
920 1106 if (array_key_exists('nofollow', $data)) {
921 1107 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
922 1108 }
923 - 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)));
924 1110 }
925 1111
926 1112 // Advanced robots: max-* go into rank_math_advanced_robots
927 1113 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
@@ -949,9 +1135,9 @@
949 1135 $val = (int) $data['max_video_preview'];
950 1136 $adv['max-video-preview'] = (string) $val;
951 1137 }
952 1138 if (!empty($adv)) {
953 - update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
1139 + $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
954 1140 }
955 1141 }
956 1142
957 1143 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
@@ -966,58 +1152,49 @@
966 1152 if (!empty($data[$dir])) {
967 1153 $robots[] = $dir;
968 1154 }
969 1155 }
970 - 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)));
971 1157 }
972 1158
973 1159 // OG
974 1160 if (!empty($data['og_title'])) {
975 - update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
976 - $wrote = true;
1161 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
977 1162 }
978 1163 if (!empty($data['og_desc'])) {
979 - update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
980 - $wrote = true;
1164 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
981 1165 }
982 1166 if (!empty($data['og_image'])) {
983 - 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;
984 1168 $img_id = attachment_url_to_postid($data['og_image']);
985 1169 if ($img_id) {
986 - 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;
987 1171 }
988 - $wrote = true;
989 1172 }
990 1173
991 1174 // Twitter
992 1175 if (!empty($data['twitter_title'])) {
993 - update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
994 - $wrote = true;
1176 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
995 1177 }
996 1178 if (!empty($data['twitter_desc'])) {
997 - update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
998 - $wrote = true;
1179 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
999 1180 }
1000 1181 if (!empty($data['twitter_card'])) {
1001 1182 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
1002 1183 if (in_array($data['twitter_card'], $valid_cards, true)) {
1003 - update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
1004 - $wrote = true;
1184 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
1005 1185 }
1006 1186 }
1007 1187
1008 1188 // Canonical, focus keyword, breadcrumb
1009 1189 if (!empty($data['canonical'])) {
1010 - update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
1011 - $wrote = true;
1190 + $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
1012 1191 }
1013 1192 if (!empty($data['focus_keyword'])) {
1014 - update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
1015 - $wrote = true;
1193 + $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
1016 1194 }
1017 1195 if (!empty($data['breadcrumb_title'])) {
1018 - update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
1019 - $wrote = true;
1196 + $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
1020 1197 }
1021 1198
1022 1199 return $wrote;
1023 1200 }
@@ -1022,8 +1199,121 @@
1022 1199 return $wrote;
1023 1200 }
1024 1201
1025 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 +
1312 + return true;
1313 + }
1314 +
1315 + /**
1026 1316 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
1027 1317 *
1028 1318 * @param int $post_id Post ID.
1029 1319 * @param array $data Canonical key/value pairs.
@@ -1035,12 +1325,16 @@
1035 1325 */
1036 1326 private function sync_aioseo($post_id, array $data) {
1037 1327 global $wpdb;
1038 1328
1329 + if (!$this->third_party_writes_allowed()) {
1330 + return false;
1331 + }
1332 +
1039 1333 $table = $wpdb->prefix . 'aioseo_posts';
1040 1334
1041 1335 // Bail if the AIOSEO post table does not exist (plugin not initialised).
1042 - $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
1043 1337 if ($table_exists !== $table) {
1044 1338 return false;
1045 1339 }
1046 1340
@@ -1147,17 +1441,38 @@
1147 1441 }
1148 1442
1149 1443 $row['updated'] = current_time('mysql');
1150 1444
1151 - $existing_id = $wpdb->get_var($wpdb->prepare(
1152 - "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",
1153 1447 $post_id
1154 1448 ));
1155 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.
1156 1461 if ($existing_id) {
1157 - return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote;
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
1158 1468 }
1159 1469
1470 + $backups_created = [];
1471 + if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1472 + return false;
1473 + }
1474 +
1160 1475 // New row -- must include all NOT NULL columns with no defaults
1161 1476 $row['post_id'] = $post_id;
1162 1477 $row['created'] = current_time('mysql');
1163 1478 $robot_defaults = [
@@ -1171,9 +1486,23 @@
1171 1486 'robots_notranslate' => 0,
1172 1487 ];
1173 1488 $row = array_merge($robot_defaults, $row);
1174 1489
1175 - return ($wpdb->insert($table, $row) !== false) && $wrote;
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;
1176 1505 }
1177 1506
1178 1507 // ------------------------------------------------------------------
1179 1508 // Two-way sync: sidebar JSON ↔ legacy meta boxes