PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-plugin-sync.php +380 -59 2.6.26 → 2.7.1 View file →
@@ -695,8 +695,40 @@
695 695 // Per-plugin sync
696 696 // ------------------------------------------------------------------
697 697
698 698 /**
699 + * Whether third-party SEO storage may be written at all.
700 + *
701 + * The site owner's consent switch. Checked once per plugin dispatch rather
702 + * than per field so a sync that is not permitted does no work and, more
703 + * importantly, leaves no half-written row behind.
704 + *
705 + * @return bool
706 + */
707 + private function third_party_writes_allowed() {
708 + return class_exists('Metasync_Seo_Backup') && Metasync_Seo_Backup::is_enabled();
709 + }
710 +
711 + /**
712 + * Write one third-party post-meta field, preserving what it held before.
713 + *
714 + * This bridge runs on `updated_post_meta`, which means it fires from inside
715 + * an OTTO sync's `_metasync_otto_*` write — before the same sync reaches its
716 + * own direct Rank Math / Yoast writes further down. It is therefore usually
717 + * the first code to touch the customer's value, and the backup it takes here
718 + * is the one that holds the true original.
719 + *
720 + * @param int $post_id Post ID.
721 + * @param string $key Third-party meta key.
722 + * @param mixed $value Value to write.
723 + * @return bool True when the write happened.
724 + */
725 + private function write_post_field($post_id, $key, $value) {
726 + return class_exists('Metasync_Seo_Backup')
727 + && Metasync_Seo_Backup::write_post_meta($post_id, $key, $value);
728 + }
729 +
730 + /**
699 731 * Mirror canonical data into Yoast post meta and indexable cache.
700 732 *
701 733 * @param int $post_id Post ID.
702 734 * @param array $data Canonical key/value pairs.
@@ -714,32 +746,34 @@
714 746 * Counting them would make the result unconditionally true,
715 747 * which is the bug this return contract exists to fix.
716 748 */
717 749 private function sync_yoast($post_id, array $data) {
750 + if (!$this->third_party_writes_allowed()) {
751 + return false;
752 + }
753 +
718 754 $wrote = false;
719 755
720 756 // title
721 757 if (!empty($data['title'])) {
722 - update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
723 - $wrote = true;
758 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_title', (string) $data['title']);
724 759 }
725 760
726 761 // description -- strip newlines first
727 762 if (!empty($data['desc'])) {
728 763 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
729 - update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
730 - $wrote = true;
764 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
731 765 }
732 766
733 767 // noindex: '0'=default, '1'=noindex, '2'=index
734 768 if (array_key_exists('noindex', $data)) {
735 769 $val = $data['noindex'] ? '1' : '2';
736 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
770 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
737 771 }
738 772
739 773 // nofollow: '0'=follow, '1'=nofollow
740 774 if (array_key_exists('nofollow', $data)) {
741 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
775 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
742 776 }
743 777
744 778 // advanced robots: comma-separated NO spaces
745 779 $adv = [];
@@ -754,46 +788,38 @@
754 788 }
755 789 // Written unconditionally so clearing the last directive clears the
756 790 // field. Like the other robots writes it carries no canonical value,
757 791 // so it does not make this a successful sync.
758 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
792 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
759 793
760 794 // OG
761 795 if (!empty($data['og_title'])) {
762 - update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
763 - $wrote = true;
796 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']) || $wrote;
764 797 }
765 798 if (!empty($data['og_desc'])) {
766 - update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
767 - $wrote = true;
799 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']) || $wrote;
768 800 }
769 801 if (!empty($data['og_image'])) {
770 - update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
771 - $wrote = true;
802 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])) || $wrote;
772 803 }
773 804
774 805 // Twitter
775 806 if (!empty($data['twitter_title'])) {
776 - update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
777 - $wrote = true;
807 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']) || $wrote;
778 808 }
779 809 if (!empty($data['twitter_desc'])) {
780 - update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
781 - $wrote = true;
810 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']) || $wrote;
782 811 }
783 812
784 813 // Canonical, focus keyword, breadcrumb
785 814 if (!empty($data['canonical'])) {
786 - update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
787 - $wrote = true;
815 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])) || $wrote;
788 816 }
789 817 if (!empty($data['focus_keyword'])) {
790 - update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
791 - $wrote = true;
818 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']) || $wrote;
792 819 }
793 820 if (!empty($data['breadcrumb_title'])) {
794 - update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
795 - $wrote = true;
821 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']) || $wrote;
796 822 }
797 823
798 824 // Update wp_yoast_indexable cache row for immediate effect
799 825 global $wpdb;
@@ -831,9 +857,9 @@
831 857 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
832 858 }
833 859 if (!empty($data['twitter_card'])) {
834 860 // twitter_card column only exists in newer Yoast versions; skip if absent
835 - $col_check = $wpdb->get_var($wpdb->prepare(
861 + $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 862 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
837 863 DB_NAME,
838 864 $indexable_table
839 865 ));
@@ -851,15 +877,38 @@
851 877 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
852 878 }
853 879
854 880 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'",
881 + $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
882 + "SELECT id FROM {$wpdb->prefix}yoast_indexable WHERE object_id = %d AND object_type = 'post'",
857 883 $post_id
858 884 ));
859 885
886 + // This probe decides two things at once: the value of the write-once
887 + // row_existed marker, and whether the write below is an UPDATE or an
888 + // INSERT. A failed probe answers "no row" for a row that is really
889 + // there, which commits a '0' marker telling a restore to delete the
890 + // customer's row and fires an INSERT against a row that already
891 + // exists. Neither is correctable afterwards, so an unreadable probe
892 + // has to abandon the indexable write entirely.
893 + //
894 + // The post meta above has already landed, so the receipt still
895 + // reports it. Only the indexable is skipped.
896 + if (!Metasync_Seo_Backup::db_read_succeeded()) {
897 + return $wrote;
898 + }
899 +
900 + // No original saved means no write, exactly as on the AIOSEO table.
901 + // This row is what Yoast actually renders from, so a restore that
902 + // cannot reach it puts the meta back and leaves the customer's live
903 + // pages still showing OTTO's values.
904 + $backups_created = [];
905 + if (!$this->backup_yoast_indexable_columns($post_id, $indexable_table, $updates, (bool) $row_exists, $backups_created)) {
906 + return $wrote;
907 + }
908 +
860 909 if ($row_exists) {
861 - $wpdb->update(
910 + $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 911 $indexable_table,
863 912 $updates,
864 913 ['object_id' => $post_id, 'object_type' => 'post']
865 914 );
@@ -879,9 +928,28 @@
879 928 'is_cornerstone' => 0,
880 929 'created_at' => current_time('mysql'),
881 930 'updated_at' => current_time('mysql'),
882 931 ], $updates);
883 - $wpdb->insert($indexable_table, $insert);
932 +
933 + // The row_existed='0' marker was recorded before this insert,
934 + // because a marker that will not save has to be able to veto the
935 + // write. A failed insert means no row of ours exists, and a
936 + // marker left saying otherwise would let a restore delete a row
937 + // Yoast or the customer creates afterwards. The per-column
938 + // backups recorded beside it go too: they are write-once, so a
939 + // column captured as NULL because there was no row would stay
940 + // NULL for good and blank a real value the customer later puts
941 + // in that row.
942 + //
943 + // The post meta above has already been written, so the return
944 + // still reports what landed. Claiming nothing was written would
945 + // tell the conflict handler this post is unsynced to Yoast while
946 + // Yoast's own meta holds our values, and it would hand tag
947 + // ownership to the wrong plugin.
948 + 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
949 + Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
950 + return $wrote;
951 + }
884 952 }
885 953 }
886 954
887 955 return $wrote;
@@ -887,8 +955,116 @@
887 955 return $wrote;
888 956 }
889 957
890 958 /**
959 + * Preserve the wp_yoast_indexable columns this sync is about to overwrite.
960 + *
961 + * Yoast serves the frontend from this table, not from post meta, so the
962 + * meta backups taken by write_post_field() do not by themselves make the
963 + * change reversible. Same shape as backup_aioseo_columns():
964 + *
965 + * - one backup per column being written, so a restore can put the originals
966 + * back and leave every other column of the user's row alone;
967 + * - whether the row existed at all, so a restore can delete a row that only
968 + * exists because we created it instead of leaving an empty shell behind.
969 + *
970 + * @param int $post_id Post ID.
971 + * @param string $table Fully prefixed indexable table name.
972 + * @param array $updates Columns and values about to be written.
973 + * @param bool $row_existed Whether Yoast already had a row for this post.
974 + * @param array $created Out-param, filled with the backup fields this
975 + * call created, so a failed write can withdraw
976 + * exactly its own rows and no one else's.
977 + * @return bool True when every original was preserved and the caller may write.
978 + */
979 + private function backup_yoast_indexable_columns($post_id, $table, array $updates, $row_existed, array &$created) {
980 + $created = [];
981 +
982 + if (!class_exists('Metasync_Seo_Backup')) {
983 + return false;
984 + }
985 +
986 + global $wpdb;
987 +
988 + if (!Metasync_Seo_Backup::record_marker(
989 + 'post',
990 + $post_id,
991 + 'yoast_indexable_row_existed',
992 + $row_existed ? '1' : '0',
993 + $marker_created
994 + )) {
995 + return false;
996 + }
997 +
998 + if ($marker_created) {
999 + $created[] = 'yoast_indexable_row_existed';
1000 + }
1001 +
1002 + $columns = array_keys($updates);
1003 + if (empty($columns)) {
1004 + return true;
1005 + }
1006 +
1007 + $current = null;
1008 + if ($row_existed) {
1009 + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1010 + // Column list is esc_sql()'d above; the table is a fixed literal.
1011 + $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
1012 + $wpdb->prepare(
1013 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal
1014 + "SELECT {$select} FROM {$wpdb->prefix}yoast_indexable WHERE object_id = %d AND object_type = 'post'",
1015 + $post_id
1016 + ),
1017 + ARRAY_A
1018 + );
1019 +
1020 + // The row was there a moment ago, so a null answer now is a failed
1021 + // read, not an empty row. Recording it as "every column was NULL"
1022 + // would tell a later restore to delete values it should put back,
1023 + // which is the exact loss this layer exists to prevent. No write is
1024 + // happening, so the marker recorded above has to go too — a marker
1025 + // left describing a write that never ran is a stale story.
1026 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1027 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1028 + return false;
1029 + }
1030 + }
1031 +
1032 + foreach ($columns as $column) {
1033 + // A missing row and a NULL column are the same thing to a restore:
1034 + // there was no value here, so put nothing back.
1035 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1036 +
1037 + $field = 'yoast_indexable_' . $column;
1038 +
1039 + // One unsaved column is enough to refuse the whole write: a
1040 + // half-original, half-OTTO row is something no restore can unpick.
1041 + // The refusal also means the caller writes nothing, so withdraw the
1042 + // marker and the columns recorded so far — the same rule as the
1043 + // failed-insert path in sync_yoast(). Left behind, a row_existed='0'
1044 + // marker would let a restore delete a row the customer creates
1045 + // later, and a NULL column backup would blank a real value in it.
1046 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1047 + 'post',
1048 + $post_id,
1049 + $field,
1050 + $updates[$column],
1051 + $current_value,
1052 + $column_created
1053 + )) {
1054 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1055 + return false;
1056 + }
1057 +
1058 + if ($column_created) {
1059 + $created[] = $field;
1060 + }
1061 + }
1062 +
1063 + return true;
1064 + }
1065 +
1066 + /**
891 1067 * Mirror canonical data into Rank Math post meta.
892 1068 *
893 1069 * @param int $post_id Post ID.
894 1070 * @param array $data Canonical key/value pairs.
@@ -896,18 +1072,20 @@
896 1072 * false when the payload carried nothing to write. Robots
897 1073 * directives are excluded for the reason given on sync_yoast().
898 1074 */
899 1075 private function sync_rankmath($post_id, array $data) {
1076 + if (!$this->third_party_writes_allowed()) {
1077 + return false;
1078 + }
1079 +
900 1080 $wrote = false;
901 1081
902 1082 // title, desc
903 1083 if (!empty($data['title'])) {
904 - update_post_meta($post_id, 'rank_math_title', $data['title']);
905 - $wrote = true;
1084 + $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
906 1085 }
907 1086 if (!empty($data['desc'])) {
908 - update_post_meta($post_id, 'rank_math_description', $data['desc']);
909 - $wrote = true;
1087 + $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
910 1088 }
911 1089
912 1090 // robots: PHP indexed array
913 1091 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
@@ -919,9 +1097,9 @@
919 1097 }
920 1098 if (array_key_exists('nofollow', $data)) {
921 1099 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
922 1100 }
923 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1101 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
924 1102 }
925 1103
926 1104 // Advanced robots: max-* go into rank_math_advanced_robots
927 1105 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
@@ -949,9 +1127,9 @@
949 1127 $val = (int) $data['max_video_preview'];
950 1128 $adv['max-video-preview'] = (string) $val;
951 1129 }
952 1130 if (!empty($adv)) {
953 - update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
1131 + $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
954 1132 }
955 1133 }
956 1134
957 1135 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
@@ -966,58 +1144,49 @@
966 1144 if (!empty($data[$dir])) {
967 1145 $robots[] = $dir;
968 1146 }
969 1147 }
970 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1148 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
971 1149 }
972 1150
973 1151 // OG
974 1152 if (!empty($data['og_title'])) {
975 - update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
976 - $wrote = true;
1153 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
977 1154 }
978 1155 if (!empty($data['og_desc'])) {
979 - update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
980 - $wrote = true;
1156 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
981 1157 }
982 1158 if (!empty($data['og_image'])) {
983 - update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
1159 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])) || $wrote;
984 1160 $img_id = attachment_url_to_postid($data['og_image']);
985 1161 if ($img_id) {
986 - update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
1162 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image_id', $img_id) || $wrote;
987 1163 }
988 - $wrote = true;
989 1164 }
990 1165
991 1166 // Twitter
992 1167 if (!empty($data['twitter_title'])) {
993 - update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
994 - $wrote = true;
1168 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
995 1169 }
996 1170 if (!empty($data['twitter_desc'])) {
997 - update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
998 - $wrote = true;
1171 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
999 1172 }
1000 1173 if (!empty($data['twitter_card'])) {
1001 1174 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
1002 1175 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;
1176 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
1005 1177 }
1006 1178 }
1007 1179
1008 1180 // Canonical, focus keyword, breadcrumb
1009 1181 if (!empty($data['canonical'])) {
1010 - update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
1011 - $wrote = true;
1182 + $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
1012 1183 }
1013 1184 if (!empty($data['focus_keyword'])) {
1014 - update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
1015 - $wrote = true;
1185 + $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
1016 1186 }
1017 1187 if (!empty($data['breadcrumb_title'])) {
1018 - update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
1019 - $wrote = true;
1188 + $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
1020 1189 }
1021 1190
1022 1191 return $wrote;
1023 1192 }
@@ -1022,8 +1191,121 @@
1022 1191 return $wrote;
1023 1192 }
1024 1193
1025 1194 /**
1195 + * Preserve the AIOSEO columns a sync is about to overwrite.
1196 + *
1197 + * AIOSEO keeps post SEO data in its own `aioseo_posts` table rather than in
1198 + * post meta, so there is no meta row to save and no field a restore could
1199 + * delete. Two things are recorded instead, both as post meta on the post
1200 + * itself:
1201 + *
1202 + * - one backup per column being written, so a restore can put the original
1203 + * values back and leave every other column of the user's row alone;
1204 + * - whether the row existed at all, so a restore can delete a row that only
1205 + * exists because we created it instead of leaving an empty shell behind.
1206 + *
1207 + * @param int $post_id Post ID.
1208 + * @param string $table Fully prefixed AIOSEO table name.
1209 + * @param array $row Columns and values about to be written.
1210 + * @param bool $row_existed Whether AIOSEO already had a row for this post.
1211 + * @param array $created Out-param, filled with the backup fields this
1212 + * call created, so a failed write can withdraw
1213 + * exactly its own rows and no one else's.
1214 + * @return bool True when every original was preserved and the caller may write.
1215 + */
1216 + private function backup_aioseo_columns($post_id, $table, array $row, $row_existed, array &$created) {
1217 + $created = [];
1218 +
1219 + if (!class_exists('Metasync_Seo_Backup')) {
1220 + return false;
1221 + }
1222 +
1223 + global $wpdb;
1224 +
1225 + // Whether the row pre-existed is what a restore uses to choose between
1226 + // putting the original columns back and deleting a row that only exists
1227 + // because we made it. A marker that will not record is as disqualifying
1228 + // as a column that will not.
1229 + if (!Metasync_Seo_Backup::record_marker(
1230 + 'post',
1231 + $post_id,
1232 + 'aioseo_row_existed',
1233 + $row_existed ? '1' : '0',
1234 + $marker_created
1235 + )) {
1236 + return false;
1237 + }
1238 +
1239 + if ($marker_created) {
1240 + $created[] = 'aioseo_row_existed';
1241 + }
1242 +
1243 + $columns = array_diff(array_keys($row), ['updated', 'created', 'post_id']);
1244 + if (empty($columns)) {
1245 + return true;
1246 + }
1247 +
1248 + $current = null;
1249 + if ($row_existed) {
1250 + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1251 + // Column list is esc_sql()'d above; the table is a fixed literal.
1252 + $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
1253 + $wpdb->prepare(
1254 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal
1255 + "SELECT {$select} FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d",
1256 + $post_id
1257 + ),
1258 + ARRAY_A
1259 + );
1260 +
1261 + // The row was there a moment ago, so a null answer now is a failed
1262 + // read, not an empty row. Recording it as "every column was NULL"
1263 + // would tell a later restore to delete values it should put back,
1264 + // which is the exact loss this layer exists to prevent. No write is
1265 + // happening, so the marker recorded above has to go too — a marker
1266 + // left describing a write that never ran is a stale story.
1267 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1268 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1269 + return false;
1270 + }
1271 + }
1272 +
1273 + foreach ($columns as $column) {
1274 + // A missing row and a NULL column are the same thing to a restore:
1275 + // there was no value here, so put nothing back.
1276 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1277 +
1278 + $field = 'aioseo_' . $column;
1279 +
1280 + // One unsaved column is enough to refuse the whole write: a half-original,
1281 + // half-OTTO row is something no restore can unpick. The refusal also
1282 + // means the caller writes nothing, so withdraw the marker and the
1283 + // columns recorded so far — the same rule as the failed-insert path
1284 + // in sync_aioseo(). Left behind, a row_existed='0' marker would let
1285 + // a restore delete a row the customer creates later, and a NULL
1286 + // column backup would blank a real value in it.
1287 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1288 + 'post',
1289 + $post_id,
1290 + $field,
1291 + $row[$column],
1292 + $current_value,
1293 + $column_created
1294 + )) {
1295 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1296 + return false;
1297 + }
1298 +
1299 + if ($column_created) {
1300 + $created[] = $field;
1301 + }
1302 + }
1303 +
1304 + return true;
1305 + }
1306 +
1307 + /**
1026 1308 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
1027 1309 *
1028 1310 * @param int $post_id Post ID.
1029 1311 * @param array $data Canonical key/value pairs.
@@ -1035,12 +1317,16 @@
1035 1317 */
1036 1318 private function sync_aioseo($post_id, array $data) {
1037 1319 global $wpdb;
1038 1320
1321 + if (!$this->third_party_writes_allowed()) {
1322 + return false;
1323 + }
1324 +
1039 1325 $table = $wpdb->prefix . 'aioseo_posts';
1040 1326
1041 1327 // 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));
1328 + $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 1329 if ($table_exists !== $table) {
1044 1330 return false;
1045 1331 }
1046 1332
@@ -1147,17 +1433,38 @@
1147 1433 }
1148 1434
1149 1435 $row['updated'] = current_time('mysql');
1150 1436
1151 - $existing_id = $wpdb->get_var($wpdb->prepare(
1152 - "SELECT id FROM {$table} WHERE post_id = %d",
1437 + $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
1438 + "SELECT id FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d",
1153 1439 $post_id
1154 1440 ));
1155 1441
1442 + // An unreadable probe cannot be treated as "no row". It would commit a
1443 + // write-once row_existed='0' for a row AIOSEO really has -- which a
1444 + // restore reads as licence to delete it -- and send an INSERT at a row
1445 + // that already exists. Leave AIOSEO's row alone and let the next sync
1446 + // record the truth.
1447 + if (!Metasync_Seo_Backup::db_read_succeeded()) {
1448 + return false;
1449 + }
1450 +
1451 + // No original saved means no write. Overwriting anyway is the data loss
1452 + // this whole layer exists to prevent.
1156 1453 if ($existing_id) {
1157 - return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote;
1454 + $backups_created = [];
1455 + if (!$this->backup_aioseo_columns($post_id, $table, $row, true, $backups_created)) {
1456 + return false;
1457 + }
1458 +
1459 + 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 1460 }
1159 1461
1462 + $backups_created = [];
1463 + if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1464 + return false;
1465 + }
1466 +
1160 1467 // New row -- must include all NOT NULL columns with no defaults
1161 1468 $row['post_id'] = $post_id;
1162 1469 $row['created'] = current_time('mysql');
1163 1470 $robot_defaults = [
@@ -1171,9 +1478,23 @@
1171 1478 'robots_notranslate' => 0,
1172 1479 ];
1173 1480 $row = array_merge($robot_defaults, $row);
1174 1481
1175 - return ($wpdb->insert($table, $row) !== false) && $wrote;
1482 + $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
1483 +
1484 + // The row_existed='0' marker was recorded before the insert, because a
1485 + // marker that will not save has to be able to veto the write. If the
1486 + // insert then failed there is no row of ours, and leaving the marker
1487 + // behind would let a restore delete a row the customer creates later.
1488 + // The per-column backups beside it go too, or a column captured as NULL
1489 + // for a row that never existed would blank a real value the customer
1490 + // later puts in one.
1491 + if ($inserted === false) {
1492 + Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
1493 + return false;
1494 + }
1495 +
1496 + return $wrote;
1176 1497 }
1177 1498
1178 1499 // ------------------------------------------------------------------
1179 1500 // Two-way sync: sidebar JSON ↔ legacy meta boxes