PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 All 139 releases
← All changes | includes/class-metasync-plugin-sync.php +365 -51 2.6.26trunk 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;
@@ -856,8 +882,31 @@
856 882 "SELECT id FROM {$indexable_table} 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 910 $wpdb->update(
862 911 $indexable_table,
863 912 $updates,
@@ -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) {
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,114 @@
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 + $current = $wpdb->get_row(
1011 + $wpdb->prepare(
1012 + "SELECT {$select} FROM {$table} WHERE object_id = %d AND object_type = 'post'",
1013 + $post_id
1014 + ),
1015 + ARRAY_A
1016 + );
1017 +
1018 + // The row was there a moment ago, so a null answer now is a failed
1019 + // read, not an empty row. Recording it as "every column was NULL"
1020 + // would tell a later restore to delete values it should put back,
1021 + // which is the exact loss this layer exists to prevent. No write is
1022 + // happening, so the marker recorded above has to go too — a marker
1023 + // left describing a write that never ran is a stale story.
1024 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1025 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1026 + return false;
1027 + }
1028 + }
1029 +
1030 + foreach ($columns as $column) {
1031 + // A missing row and a NULL column are the same thing to a restore:
1032 + // there was no value here, so put nothing back.
1033 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1034 +
1035 + $field = 'yoast_indexable_' . $column;
1036 +
1037 + // One unsaved column is enough to refuse the whole write: a
1038 + // half-original, half-OTTO row is something no restore can unpick.
1039 + // The refusal also means the caller writes nothing, so withdraw the
1040 + // marker and the columns recorded so far — the same rule as the
1041 + // failed-insert path in sync_yoast(). Left behind, a row_existed='0'
1042 + // marker would let a restore delete a row the customer creates
1043 + // later, and a NULL column backup would blank a real value in it.
1044 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1045 + 'post',
1046 + $post_id,
1047 + $field,
1048 + $updates[$column],
1049 + $current_value,
1050 + $column_created
1051 + )) {
1052 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1053 + return false;
1054 + }
1055 +
1056 + if ($column_created) {
1057 + $created[] = $field;
1058 + }
1059 + }
1060 +
1061 + return true;
1062 + }
1063 +
1064 + /**
891 1065 * Mirror canonical data into Rank Math post meta.
892 1066 *
893 1067 * @param int $post_id Post ID.
894 1068 * @param array $data Canonical key/value pairs.
@@ -896,18 +1070,20 @@
896 1070 * false when the payload carried nothing to write. Robots
897 1071 * directives are excluded for the reason given on sync_yoast().
898 1072 */
899 1073 private function sync_rankmath($post_id, array $data) {
1074 + if (!$this->third_party_writes_allowed()) {
1075 + return false;
1076 + }
1077 +
900 1078 $wrote = false;
901 1079
902 1080 // title, desc
903 1081 if (!empty($data['title'])) {
904 - update_post_meta($post_id, 'rank_math_title', $data['title']);
905 - $wrote = true;
1082 + $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
906 1083 }
907 1084 if (!empty($data['desc'])) {
908 - update_post_meta($post_id, 'rank_math_description', $data['desc']);
909 - $wrote = true;
1085 + $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
910 1086 }
911 1087
912 1088 // robots: PHP indexed array
913 1089 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
@@ -919,9 +1095,9 @@
919 1095 }
920 1096 if (array_key_exists('nofollow', $data)) {
921 1097 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
922 1098 }
923 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1099 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
924 1100 }
925 1101
926 1102 // Advanced robots: max-* go into rank_math_advanced_robots
927 1103 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
@@ -949,9 +1125,9 @@
949 1125 $val = (int) $data['max_video_preview'];
950 1126 $adv['max-video-preview'] = (string) $val;
951 1127 }
952 1128 if (!empty($adv)) {
953 - update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
1129 + $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
954 1130 }
955 1131 }
956 1132
957 1133 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
@@ -966,58 +1142,49 @@
966 1142 if (!empty($data[$dir])) {
967 1143 $robots[] = $dir;
968 1144 }
969 1145 }
970 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1146 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
971 1147 }
972 1148
973 1149 // OG
974 1150 if (!empty($data['og_title'])) {
975 - update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
976 - $wrote = true;
1151 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
977 1152 }
978 1153 if (!empty($data['og_desc'])) {
979 - update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
980 - $wrote = true;
1154 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
981 1155 }
982 1156 if (!empty($data['og_image'])) {
983 - update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
1157 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])) || $wrote;
984 1158 $img_id = attachment_url_to_postid($data['og_image']);
985 1159 if ($img_id) {
986 - update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
1160 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image_id', $img_id) || $wrote;
987 1161 }
988 - $wrote = true;
989 1162 }
990 1163
991 1164 // Twitter
992 1165 if (!empty($data['twitter_title'])) {
993 - update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
994 - $wrote = true;
1166 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
995 1167 }
996 1168 if (!empty($data['twitter_desc'])) {
997 - update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
998 - $wrote = true;
1169 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
999 1170 }
1000 1171 if (!empty($data['twitter_card'])) {
1001 1172 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
1002 1173 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;
1174 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
1005 1175 }
1006 1176 }
1007 1177
1008 1178 // Canonical, focus keyword, breadcrumb
1009 1179 if (!empty($data['canonical'])) {
1010 - update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
1011 - $wrote = true;
1180 + $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
1012 1181 }
1013 1182 if (!empty($data['focus_keyword'])) {
1014 - update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
1015 - $wrote = true;
1183 + $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
1016 1184 }
1017 1185 if (!empty($data['breadcrumb_title'])) {
1018 - update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
1019 - $wrote = true;
1186 + $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
1020 1187 }
1021 1188
1022 1189 return $wrote;
1023 1190 }
@@ -1022,8 +1189,116 @@
1022 1189 return $wrote;
1023 1190 }
1024 1191
1025 1192 /**
1193 + * Preserve the AIOSEO columns a sync is about to overwrite.
1194 + *
1195 + * AIOSEO keeps post SEO data in its own `aioseo_posts` table rather than in
1196 + * post meta, so there is no meta row to save and no field a restore could
1197 + * delete. Two things are recorded instead, both as post meta on the post
1198 + * itself:
1199 + *
1200 + * - one backup per column being written, so a restore can put the original
1201 + * values back and leave every other column of the user's row alone;
1202 + * - whether the row existed at all, so a restore can delete a row that only
1203 + * exists because we created it instead of leaving an empty shell behind.
1204 + *
1205 + * @param int $post_id Post ID.
1206 + * @param string $table Fully prefixed AIOSEO table name.
1207 + * @param array $row Columns and values about to be written.
1208 + * @param bool $row_existed Whether AIOSEO already had a row for this post.
1209 + * @param array $created Out-param, filled with the backup fields this
1210 + * call created, so a failed write can withdraw
1211 + * exactly its own rows and no one else's.
1212 + * @return bool True when every original was preserved and the caller may write.
1213 + */
1214 + private function backup_aioseo_columns($post_id, $table, array $row, $row_existed, array &$created) {
1215 + $created = [];
1216 +
1217 + if (!class_exists('Metasync_Seo_Backup')) {
1218 + return false;
1219 + }
1220 +
1221 + global $wpdb;
1222 +
1223 + // Whether the row pre-existed is what a restore uses to choose between
1224 + // putting the original columns back and deleting a row that only exists
1225 + // because we made it. A marker that will not record is as disqualifying
1226 + // as a column that will not.
1227 + if (!Metasync_Seo_Backup::record_marker(
1228 + 'post',
1229 + $post_id,
1230 + 'aioseo_row_existed',
1231 + $row_existed ? '1' : '0',
1232 + $marker_created
1233 + )) {
1234 + return false;
1235 + }
1236 +
1237 + if ($marker_created) {
1238 + $created[] = 'aioseo_row_existed';
1239 + }
1240 +
1241 + $columns = array_diff(array_keys($row), ['updated', 'created', 'post_id']);
1242 + if (empty($columns)) {
1243 + return true;
1244 + }
1245 +
1246 + $current = null;
1247 + if ($row_existed) {
1248 + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1249 + $current = $wpdb->get_row(
1250 + $wpdb->prepare("SELECT {$select} FROM {$table} WHERE post_id = %d", $post_id),
1251 + ARRAY_A
1252 + );
1253 +
1254 + // The row was there a moment ago, so a null answer now is a failed
1255 + // read, not an empty row. Recording it as "every column was NULL"
1256 + // would tell a later restore to delete values it should put back,
1257 + // which is the exact loss this layer exists to prevent. No write is
1258 + // happening, so the marker recorded above has to go too — a marker
1259 + // left describing a write that never ran is a stale story.
1260 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1261 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1262 + return false;
1263 + }
1264 + }
1265 +
1266 + foreach ($columns as $column) {
1267 + // A missing row and a NULL column are the same thing to a restore:
1268 + // there was no value here, so put nothing back.
1269 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1270 +
1271 + $field = 'aioseo_' . $column;
1272 +
1273 + // One unsaved column is enough to refuse the whole write: a half-original,
1274 + // half-OTTO row is something no restore can unpick. The refusal also
1275 + // means the caller writes nothing, so withdraw the marker and the
1276 + // columns recorded so far — the same rule as the failed-insert path
1277 + // in sync_aioseo(). Left behind, a row_existed='0' marker would let
1278 + // a restore delete a row the customer creates later, and a NULL
1279 + // column backup would blank a real value in it.
1280 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1281 + 'post',
1282 + $post_id,
1283 + $field,
1284 + $row[$column],
1285 + $current_value,
1286 + $column_created
1287 + )) {
1288 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1289 + return false;
1290 + }
1291 +
1292 + if ($column_created) {
1293 + $created[] = $field;
1294 + }
1295 + }
1296 +
1297 + return true;
1298 + }
1299 +
1300 + /**
1026 1301 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
1027 1302 *
1028 1303 * @param int $post_id Post ID.
1029 1304 * @param array $data Canonical key/value pairs.
@@ -1035,8 +1310,12 @@
1035 1310 */
1036 1311 private function sync_aioseo($post_id, array $data) {
1037 1312 global $wpdb;
1038 1313
1314 + if (!$this->third_party_writes_allowed()) {
1315 + return false;
1316 + }
1317 +
1039 1318 $table = $wpdb->prefix . 'aioseo_posts';
1040 1319
1041 1320 // Bail if the AIOSEO post table does not exist (plugin not initialised).
1042 1321 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
@@ -1152,12 +1431,33 @@
1152 1431 "SELECT id FROM {$table} WHERE post_id = %d",
1153 1432 $post_id
1154 1433 ));
1155 1434
1435 + // An unreadable probe cannot be treated as "no row". It would commit a
1436 + // write-once row_existed='0' for a row AIOSEO really has -- which a
1437 + // restore reads as licence to delete it -- and send an INSERT at a row
1438 + // that already exists. Leave AIOSEO's row alone and let the next sync
1439 + // record the truth.
1440 + if (!Metasync_Seo_Backup::db_read_succeeded()) {
1441 + return false;
1442 + }
1443 +
1444 + // No original saved means no write. Overwriting anyway is the data loss
1445 + // this whole layer exists to prevent.
1156 1446 if ($existing_id) {
1447 + $backups_created = [];
1448 + if (!$this->backup_aioseo_columns($post_id, $table, $row, true, $backups_created)) {
1449 + return false;
1450 + }
1451 +
1157 1452 return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote;
1158 1453 }
1159 1454
1455 + $backups_created = [];
1456 + if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1457 + return false;
1458 + }
1459 +
1160 1460 // New row -- must include all NOT NULL columns with no defaults
1161 1461 $row['post_id'] = $post_id;
1162 1462 $row['created'] = current_time('mysql');
1163 1463 $robot_defaults = [
@@ -1171,9 +1471,23 @@
1171 1471 'robots_notranslate' => 0,
1172 1472 ];
1173 1473 $row = array_merge($robot_defaults, $row);
1174 1474
1175 - return ($wpdb->insert($table, $row) !== false) && $wrote;
1475 + $inserted = $wpdb->insert($table, $row);
1476 +
1477 + // The row_existed='0' marker was recorded before the insert, because a
1478 + // marker that will not save has to be able to veto the write. If the
1479 + // insert then failed there is no row of ours, and leaving the marker
1480 + // behind would let a restore delete a row the customer creates later.
1481 + // The per-column backups beside it go too, or a column captured as NULL
1482 + // for a row that never existed would blank a real value the customer
1483 + // later puts in one.
1484 + if ($inserted === false) {
1485 + Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
1486 + return false;
1487 + }
1488 +
1489 + return $wrote;
1176 1490 }
1177 1491
1178 1492 // ------------------------------------------------------------------
1179 1493 // Two-way sync: sidebar JSON ↔ legacy meta boxes