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 +561 -53 2.6.24 → 2.7.1 View file →
@@ -283,8 +283,16 @@
283 283 if ($this->is_yoast_active()) {
284 284 $sync_instance = $this;
285 285 $sync_post_id = (int) $post_id;
286 286 add_action('shutdown', function() use ($sync_instance, $sync_post_id) {
287 + // By shutdown this request has typically seeded the OG defaults
288 + // memo for the post (the sync above reads it); the row write
289 + // has since landed, so drop the memo or the re-sync below
290 + // compares against the pre-save title/excerpt.
291 + // @phpstan-ignore-next-line function.alreadyNarrowedType
292 + if (method_exists('Metasync_OpenGraph', 'clear_default_og_values_memo')) {
293 + Metasync_OpenGraph::clear_default_og_values_memo();
294 + }
287 295 $sync_instance->sync_post($sync_post_id);
288 296 }, 0);
289 297 }
290 298 return;
@@ -377,14 +385,14 @@
377 385 }
378 386 }
379 387 }
380 388
381 - return $fields;
389 + return $this->apply_feature_flags_to_payload($fields);
382 390 }
383 391
384 392 $all_meta = get_post_custom($post_id);
385 393
386 - $get = function ($key) use ($all_meta) {
394 + $get = function ($key) use ($all_meta, $post_id) {
387 395 if (!isset($all_meta[$key])) {
388 396 return '';
389 397 }
390 398 $value = is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
@@ -403,11 +411,38 @@
403 411 if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')
404 412 && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS')
405 413 && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
406 414 ) {
407 - return Metasync_OpenGraph::strip_auto_draft_title($value);
415 + $value = Metasync_OpenGraph::strip_auto_draft_title($value);
408 416 }
409 417
418 + // A social title that is a verbatim snapshot of the post title is the
419 + // old pre-fill, not a customization — mirroring it would hand a
420 + // third-party plugin a copy that a rename leaves stale (and that
421 + // outranks OTTO's fresh value there). Collapsed for the same reason
422 + // as the placeholder above; the chain in $first then falls through
423 + // to OTTO's staging key.
424 + // @phpstan-ignore-next-line function.alreadyNarrowedType
425 + if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot')
426 + && defined('Metasync_OpenGraph::TITLE_DEFAULTED_KEYS')
427 + && in_array($key, Metasync_OpenGraph::TITLE_DEFAULTED_KEYS, true)
428 + ) {
429 + $value = Metasync_OpenGraph::strip_title_snapshot($post_id, $key, $value);
430 + }
431 +
432 + // A social description that is a verbatim snapshot of the resolved
433 + // excerpt is the old pre-fill, not a customization — mirroring it
434 + // would hand a third-party plugin a copy that a later excerpt or
435 + // content edit leaves stale. Collapsed for the same reason as the
436 + // title snapshot above.
437 + // @phpstan-ignore-next-line function.alreadyNarrowedType
438 + if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot')
439 + && defined('Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS')
440 + && in_array($key, Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS, true)
441 + ) {
442 + $value = Metasync_OpenGraph::strip_description_snapshot($post_id, $key, $value);
443 + }
444 +
410 445 return $value;
411 446 };
412 447
413 448 $data = [];
@@ -435,13 +470,20 @@
435 470 }
436 471 return '';
437 472 };
438 473
439 - // title: sidebar > persisted OTTO > volatile OTTO
440 - $data['title'] = $first('_metasync_seo_title', '_metasync_metatitle', '_metasync_otto_title');
474 + // Order comes from Metasync_Seo_Precedence, the one place it is defined.
475 + //
476 + // Imported values come last so a sync triggered by something else still
477 + // reports a title rather than an empty one. They are deliberately NOT in
478 + // WATCHED_KEYS or the on_meta_updated map above: a sync mirrors the
479 + // canonical value into an active third-party plugin's own storage, and
480 + // that plugin renders at a higher effective precedence than OTTO — so
481 + // syncing on an imported write would put the imported title back in
482 + // front of OTTO, which is the exact behaviour this key exists to avoid.
483 + $data['title'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_TITLE));
441 484
442 - // desc: sidebar > persisted OTTO > volatile OTTO
443 - $data['desc'] = $first('_metasync_seo_desc', '_metasync_metadesc', '_metasync_otto_description');
485 + $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION));
444 486
445 487 // Robots directives: check _metasync_robots_advanced JSON first,
446 488 // then fall back to metasync_common_robots array + metasync_advance_robots array
447 489 $robots_json_raw = $get('_metasync_robots_advanced');
@@ -447,9 +489,13 @@
447 489 $robots_json_raw = $get('_metasync_robots_advanced');
448 490 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
449 491
450 492 if (is_array($robots_json)) {
451 - $data['noindex'] = !empty($robots_json['noindex']);
493 + $data['noindex'] = $this->resolve_synced_noindex(
494 + $robots_json,
495 + $get('_metasync_robots_index'),
496 + $get('metasync_common_robots')
497 + );
452 498 $data['nofollow'] = !empty($robots_json['nofollow']);
453 499 $data['noarchive'] = !empty($robots_json['noarchive']);
454 500 $data['nosnippet'] = !empty($robots_json['nosnippet']);
455 501 $data['noimageindex'] = !empty($robots_json['noimageindex']);
@@ -456,11 +502,15 @@
456 502 $data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null);
457 503 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
458 504 $data['max_video_preview'] = isset($robots_json['max_video_preview']) ? (int) $robots_json['max_video_preview'] : (isset($robots_json['max-video-preview']) ? (int) $robots_json['max-video-preview'] : null);
459 505 } else {
460 - // noindex from dedicated key
461 - $robots_index = $get('_metasync_robots_index');
462 - $data['noindex'] = ($robots_index === 'noindex');
506 + // noindex: dedicated key, falling back to the Common Robots checkbox
507 + // array — same sources the front-end emitter honours.
508 + $data['noindex'] = $this->resolve_synced_noindex(
509 + null,
510 + $get('_metasync_robots_index'),
511 + $get('metasync_common_robots')
512 + );
463 513
464 514 // Common robots array (serialized)
465 515 $common_raw = $get('metasync_common_robots');
466 516 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
@@ -512,11 +562,90 @@
512 562 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
513 563 $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
514 564 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
515 565
566 + return $this->apply_feature_flags_to_payload($data);
567 + }
568 +
569 + /**
570 + * Drop payload keys whose owning feature is switched off in Editor Settings.
571 + *
572 + * A disabled meta box means MetaSync must not interfere with that feature
573 + * anywhere: it emits nothing, suppresses nothing, and — the part this
574 + * guard covers — does not mirror its values into Yoast / Rank Math /
575 + * AIOSEO storage. Without the drop, a sync fired while the feature is off
576 + * resolves the MetaSync value with the feature already stripped and then
577 + * writes that result over the third-party plugin's own value (Rank Math's
578 + * stored noindex replaced by a plain 'index'). The writers are all
579 + * array_key_exists-guarded, so removing a key here skips every write of it.
580 + *
581 + * @param array $data Canonical payload keyed by canonical field name.
582 + * @return array
583 + */
584 + private function apply_feature_flags_to_payload(array $data) {
585 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) {
586 + unset(
587 + $data['noindex'],
588 + $data['nofollow'],
589 + $data['noarchive'],
590 + $data['nosnippet'],
591 + $data['noimageindex']
592 + );
593 + }
594 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::ADVANCE_ROBOTS)) {
595 + unset(
596 + $data['max_snippet'],
597 + $data['max_image_preview'],
598 + $data['max_video_preview']
599 + );
600 + }
601 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
602 + unset(
603 + $data['og_title'],
604 + $data['og_desc'],
605 + $data['og_image'],
606 + $data['twitter_title'],
607 + $data['twitter_desc'],
608 + $data['twitter_card']
609 + );
610 + }
611 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
612 + unset($data['canonical']);
613 + }
614 +
516 615 return $data;
517 616 }
518 617
618 + /**
619 + * Resolve the noindex directive for syncing the way the front-end emitter
620 + * does: any of the three sources saying noindex wins, and none can veto
621 + * another. The JSON rebuilt by sync_legacy_to_json() deliberately omits
622 + * noindex, so reading the JSON alone reported a checkbox-only noindex as
623 + * false — and the writers translated that into an explicit 'index' that
624 + * overwrote a noindex the third-party plugin already held.
625 + *
626 + * @param mixed $robots_json Decoded _metasync_robots_advanced payload (or null).
627 + * @param mixed $robots_index Raw _metasync_robots_index meta value.
628 + * @param mixed $common_raw Raw metasync_common_robots meta value.
629 + * @return bool
630 + */
631 + private function resolve_synced_noindex($robots_json, $robots_index, $common_raw) {
632 + if (is_array($robots_json) && !empty($robots_json['noindex'])) {
633 + return true;
634 + }
635 + if ($robots_index === 'noindex') {
636 + return true;
637 + }
638 + if (!empty($common_raw)) {
639 + $common = maybe_unserialize($common_raw);
640 + if (is_array($common) && !empty($common['noindex'])) {
641 + return true;
642 + }
643 + }
644 +
645 + return false;
646 + }
647 +
519 648 // ------------------------------------------------------------------
520 649 // Plugin detectors
521 650 // ------------------------------------------------------------------
522 651
@@ -566,35 +695,85 @@
566 695 // Per-plugin sync
567 696 // ------------------------------------------------------------------
568 697
569 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 + /**
570 731 * Mirror canonical data into Yoast post meta and indexable cache.
571 732 *
572 733 * @param int $post_id Post ID.
573 734 * @param array $data Canonical key/value pairs.
574 - * @return bool True once dispatch completes.
735 + * @return bool True when at least one value-bearing field was mirrored,
736 + * false when the payload carried nothing to write. The caller
737 + * stamps `_metasync_plugin_sync_ts` from this, so it must
738 + * describe what was written and not merely that dispatch was
739 + * reached.
740 + *
741 + * Robots directives are excluded from that answer on purpose.
742 + * collect_post_data() synthesises noindex/nofollow on every
743 + * payload — absent any stored value they resolve to plain
744 + * index/follow — so they are written on every sync and say
745 + * nothing about whether a canonical value existed to mirror.
746 + * Counting them would make the result unconditionally true,
747 + * which is the bug this return contract exists to fix.
575 748 */
576 749 private function sync_yoast($post_id, array $data) {
750 + if (!$this->third_party_writes_allowed()) {
751 + return false;
752 + }
753 +
754 + $wrote = false;
755 +
577 756 // title
578 757 if (!empty($data['title'])) {
579 - update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
758 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_title', (string) $data['title']);
580 759 }
581 760
582 761 // description -- strip newlines first
583 762 if (!empty($data['desc'])) {
584 763 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
585 - update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
764 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
586 765 }
587 766
588 767 // noindex: '0'=default, '1'=noindex, '2'=index
589 768 if (array_key_exists('noindex', $data)) {
590 769 $val = $data['noindex'] ? '1' : '2';
591 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
770 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
592 771 }
593 772
594 773 // nofollow: '0'=follow, '1'=nofollow
595 774 if (array_key_exists('nofollow', $data)) {
596 - 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');
597 776 }
598 777
599 778 // advanced robots: comma-separated NO spaces
600 779 $adv = [];
@@ -606,38 +785,41 @@
606 785 }
607 786 if (!empty($data['noimageindex'])) {
608 787 $adv[] = 'noimageindex';
609 788 }
610 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
789 + // Written unconditionally so clearing the last directive clears the
790 + // field. Like the other robots writes it carries no canonical value,
791 + // so it does not make this a successful sync.
792 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
611 793
612 794 // OG
613 795 if (!empty($data['og_title'])) {
614 - update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
796 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']) || $wrote;
615 797 }
616 798 if (!empty($data['og_desc'])) {
617 - update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
799 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']) || $wrote;
618 800 }
619 801 if (!empty($data['og_image'])) {
620 - update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
802 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])) || $wrote;
621 803 }
622 804
623 805 // Twitter
624 806 if (!empty($data['twitter_title'])) {
625 - update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
807 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']) || $wrote;
626 808 }
627 809 if (!empty($data['twitter_desc'])) {
628 - update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
810 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']) || $wrote;
629 811 }
630 812
631 813 // Canonical, focus keyword, breadcrumb
632 814 if (!empty($data['canonical'])) {
633 - update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
815 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])) || $wrote;
634 816 }
635 817 if (!empty($data['focus_keyword'])) {
636 - update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
818 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']) || $wrote;
637 819 }
638 820 if (!empty($data['breadcrumb_title'])) {
639 - update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
821 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']) || $wrote;
640 822 }
641 823
642 824 // Update wp_yoast_indexable cache row for immediate effect
643 825 global $wpdb;
@@ -675,9 +857,9 @@
675 857 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
676 858 }
677 859 if (!empty($data['twitter_card'])) {
678 860 // twitter_card column only exists in newer Yoast versions; skip if absent
679 - $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
680 862 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
681 863 DB_NAME,
682 864 $indexable_table
683 865 ));
@@ -695,15 +877,38 @@
695 877 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
696 878 }
697 879
698 880 if (!empty($updates)) {
699 - $row_exists = $wpdb->get_var($wpdb->prepare(
700 - "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
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'",
701 883 $post_id
702 884 ));
703 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 +
704 909 if ($row_exists) {
705 - $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
706 911 $indexable_table,
707 912 $updates,
708 913 ['object_id' => $post_id, 'object_type' => 'post']
709 914 );
@@ -723,12 +928,139 @@
723 928 'is_cornerstone' => 0,
724 929 'created_at' => current_time('mysql'),
725 930 'updated_at' => current_time('mysql'),
726 931 ], $updates);
727 - $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 + }
728 952 }
729 953 }
730 954
955 + return $wrote;
956 + }
957 +
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 +
731 1063 return true;
732 1064 }
733 1065
734 1066 /**
@@ -735,17 +1067,25 @@
735 1067 * Mirror canonical data into Rank Math post meta.
736 1068 *
737 1069 * @param int $post_id Post ID.
738 1070 * @param array $data Canonical key/value pairs.
739 - * @return bool True once dispatch completes.
1071 + * @return bool True when at least one value-bearing field was mirrored,
1072 + * false when the payload carried nothing to write. Robots
1073 + * directives are excluded for the reason given on sync_yoast().
740 1074 */
741 1075 private function sync_rankmath($post_id, array $data) {
1076 + if (!$this->third_party_writes_allowed()) {
1077 + return false;
1078 + }
1079 +
1080 + $wrote = false;
1081 +
742 1082 // title, desc
743 1083 if (!empty($data['title'])) {
744 - update_post_meta($post_id, 'rank_math_title', $data['title']);
1084 + $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
745 1085 }
746 1086 if (!empty($data['desc'])) {
747 - update_post_meta($post_id, 'rank_math_description', $data['desc']);
1087 + $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
748 1088 }
749 1089
750 1090 // robots: PHP indexed array
751 1091 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
@@ -757,9 +1097,9 @@
757 1097 }
758 1098 if (array_key_exists('nofollow', $data)) {
759 1099 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
760 1100 }
761 - 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)));
762 1102 }
763 1103
764 1104 // Advanced robots: max-* go into rank_math_advanced_robots
765 1105 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
@@ -787,9 +1127,9 @@
787 1127 $val = (int) $data['max_video_preview'];
788 1128 $adv['max-video-preview'] = (string) $val;
789 1129 }
790 1130 if (!empty($adv)) {
791 - update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
1131 + $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
792 1132 }
793 1133 }
794 1134
795 1135 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
@@ -804,51 +1144,164 @@
804 1144 if (!empty($data[$dir])) {
805 1145 $robots[] = $dir;
806 1146 }
807 1147 }
808 - 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)));
809 1149 }
810 1150
811 1151 // OG
812 1152 if (!empty($data['og_title'])) {
813 - update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
1153 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
814 1154 }
815 1155 if (!empty($data['og_desc'])) {
816 - update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
1156 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
817 1157 }
818 1158 if (!empty($data['og_image'])) {
819 - 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;
820 1160 $img_id = attachment_url_to_postid($data['og_image']);
821 1161 if ($img_id) {
822 - 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;
823 1163 }
824 1164 }
825 1165
826 1166 // Twitter
827 1167 if (!empty($data['twitter_title'])) {
828 - update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
1168 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
829 1169 }
830 1170 if (!empty($data['twitter_desc'])) {
831 - update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
1171 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
832 1172 }
833 1173 if (!empty($data['twitter_card'])) {
834 1174 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
835 1175 if (in_array($data['twitter_card'], $valid_cards, true)) {
836 - update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
1176 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
837 1177 }
838 1178 }
839 1179
840 1180 // Canonical, focus keyword, breadcrumb
841 1181 if (!empty($data['canonical'])) {
842 - update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
1182 + $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
843 1183 }
844 1184 if (!empty($data['focus_keyword'])) {
845 - update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
1185 + $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
846 1186 }
847 1187 if (!empty($data['breadcrumb_title'])) {
848 - update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
1188 + $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
849 1189 }
850 1190
1191 + return $wrote;
1192 + }
1193 +
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 +
851 1304 return true;
852 1305 }
853 1306
854 1307 /**
@@ -855,54 +1308,73 @@
855 1308 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
856 1309 *
857 1310 * @param int $post_id Post ID.
858 1311 * @param array $data Canonical key/value pairs.
859 - * @return bool True when the row was written, false when the table is
860 - * missing or the write failed.
1312 + * @return bool True when at least one value-bearing field was written,
1313 + * false when the table is missing, the write failed, or the
1314 + * payload carried only robots directives. Robots are excluded
1315 + * for the reason given on sync_yoast(), so the receipt means
1316 + * the same thing for all three plugins.
861 1317 */
862 1318 private function sync_aioseo($post_id, array $data) {
863 1319 global $wpdb;
864 1320
1321 + if (!$this->third_party_writes_allowed()) {
1322 + return false;
1323 + }
1324 +
865 1325 $table = $wpdb->prefix . 'aioseo_posts';
866 1326
867 1327 // Bail if the AIOSEO post table does not exist (plugin not initialised).
868 - $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
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
869 1329 if ($table_exists !== $table) {
870 1330 return false;
871 1331 }
872 1332
873 1333 $row = [];
1334 + // Robots fill $row too, so a separate flag is needed: the row is still
1335 + // written for a robots-only payload, it just is not a content sync.
1336 + $wrote = false;
874 1337
875 1338 if (!empty($data['title'])) {
876 1339 $row['title'] = sanitize_text_field($data['title']);
1340 + $wrote = true;
877 1341 }
878 1342 if (!empty($data['desc'])) {
879 1343 $row['description'] = sanitize_text_field($data['desc']);
1344 + $wrote = true;
880 1345 }
881 1346 if (!empty($data['og_title'])) {
882 1347 $row['og_title'] = sanitize_text_field($data['og_title']);
1348 + $wrote = true;
883 1349 }
884 1350 if (!empty($data['og_desc'])) {
885 1351 $row['og_description'] = sanitize_text_field($data['og_desc']);
1352 + $wrote = true;
886 1353 }
887 1354 if (!empty($data['og_image'])) {
888 1355 $row['og_image_type'] = 'custom';
889 1356 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
1357 + $wrote = true;
890 1358 }
891 1359 if (!empty($data['twitter_title'])) {
892 1360 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
1361 + $wrote = true;
893 1362 }
894 1363 if (!empty($data['twitter_desc'])) {
895 1364 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
1365 + $wrote = true;
896 1366 }
897 1367 if (!empty($data['twitter_card'])) {
898 1368 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
899 1369 if (in_array($data['twitter_card'], $valid_cards, true)) {
900 1370 $row['twitter_card'] = $data['twitter_card'];
1371 + $wrote = true;
901 1372 }
902 1373 }
903 1374 if (!empty($data['canonical'])) {
904 1375 $row['canonical_url'] = esc_url_raw($data['canonical']);
1376 + $wrote = true;
905 1377 }
906 1378
907 1379 // focus keyword as keyphrases JSON
908 1380 if (!empty($data['focus_keyword'])) {
@@ -913,8 +1385,9 @@
913 1385 'analysis' => new \stdClass(),
914 1386 ],
915 1387 'additional' => [],
916 1388 ]);
1389 + $wrote = true;
917 1390 }
918 1391
919 1392 // Robots
920 1393 $has_robots = false;
@@ -960,17 +1433,38 @@
960 1433 }
961 1434
962 1435 $row['updated'] = current_time('mysql');
963 1436
964 - $existing_id = $wpdb->get_var($wpdb->prepare(
965 - "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",
966 1439 $post_id
967 1440 ));
968 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.
969 1453 if ($existing_id) {
970 - return $wpdb->update($table, $row, ['post_id' => $post_id]) !== false;
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
971 1460 }
972 1461
1462 + $backups_created = [];
1463 + if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1464 + return false;
1465 + }
1466 +
973 1467 // New row -- must include all NOT NULL columns with no defaults
974 1468 $row['post_id'] = $post_id;
975 1469 $row['created'] = current_time('mysql');
976 1470 $robot_defaults = [
@@ -984,9 +1478,23 @@
984 1478 'robots_notranslate' => 0,
985 1479 ];
986 1480 $row = array_merge($robot_defaults, $row);
987 1481
988 - return $wpdb->insert($table, $row) !== false;
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;
989 1497 }
990 1498
991 1499 // ------------------------------------------------------------------
992 1500 // Two-way sync: sidebar JSON ↔ legacy meta boxes