|
@@ -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 |