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 +707 -55 2.6.20 → 2.7.1 View file →
@@ -6,8 +6,12 @@
6 6 * SEO plugins' post storage (Yoast, Rank Math, AIOSEO) so that posts and
7 7 * pages render MetaSync-managed values regardless of which plugin is
8 8 * actually rendering the frontend.
9 9 *
10 + * Manually edited fields mirror immediately. Values that OTTO generated for
11 + * itself are permitted into third-party storage only while the matching OTTO
12 + * Persistence setting is enabled — see OTTO_PERSISTENCE_KEYS.
13 + *
10 14 * @package MetaSync
11 15 * @subpackage MetaSync/includes
12 16 * @since 2.8.25
13 17 */
@@ -79,8 +83,34 @@
79 83 '_metasync_otto_keywords',
80 84 ];
81 85
82 86 /**
87 + * Internal OTTO meta keys mapped to the OTTO Persistence setting that
88 + * governs whether their value may reach third-party SEO plugin storage.
89 + *
90 + * OTTO writes these keys on every sync regardless of any setting, because
91 + * its own render filters and the admin reporting columns read them. Copying
92 + * one into Yoast / Rank Math / AIOSEO is a different act: it is a permanent
93 + * write into another plugin's storage that outlives MetaSync, which is
94 + * exactly what the Persistence settings exist to control.
95 + *
96 + * Manually edited MetaSync fields are deliberately absent from this map.
97 + * Saving those in the editor is an explicit user action, so they sync
98 + * immediately and never consult these settings.
99 + *
100 + * @var array<string,string>
101 + */
102 + const OTTO_PERSISTENCE_KEYS = [
103 + '_metasync_otto_title' => 'meta_title',
104 + '_metasync_otto_description' => 'meta_description',
105 + '_metasync_otto_keywords' => 'meta_keywords',
106 + '_metasync_otto_og_title' => 'og_title',
107 + '_metasync_otto_og_description' => 'og_description',
108 + '_metasync_otto_twitter_title' => 'twitter_title',
109 + '_metasync_otto_twitter_description' => 'twitter_description',
110 + ];
111 +
112 + /**
83 113 * Get singleton instance.
84 114 *
85 115 * @return self
86 116 */
@@ -91,8 +121,35 @@
91 121 return self::$instance;
92 122 }
93 123
94 124 /**
125 + * Whether a meta key's value may be mirrored into third-party SEO storage.
126 + *
127 + * Returns true for every key that is not an internal OTTO key, so manually
128 + * edited MetaSync fields are unaffected. For the OTTO keys the matching
129 + * Persistence setting decides.
130 + *
131 + * The class_exists() guard mirrors the call sites in
132 + * otto/metasync-otto-seo-functions.php, including their fail-closed
133 + * behaviour: a partially updated install can leave this file newer than the
134 + * settings class, and with the class missing there is no setting that could
135 + * authorise a permanent write into another plugin's storage.
136 + *
137 + * @param string $meta_key Meta key being written.
138 + * @return bool True when the value may be synced.
139 + */
140 + private function otto_persistence_allows($meta_key) {
141 + if (!isset(self::OTTO_PERSISTENCE_KEYS[$meta_key])) {
142 + return true;
143 + }
144 +
145 + return class_exists('Metasync_Otto_Persistence_Settings')
146 + && Metasync_Otto_Persistence_Settings::should_persist(
147 + self::OTTO_PERSISTENCE_KEYS[$meta_key]
148 + );
149 + }
150 +
151 + /**
95 152 * Sync MetaSync post meta to every active SEO plugin.
96 153 *
97 154 * When $fields is non-empty only those canonical keys are synced.
98 155 * When empty a full sync of all canonical keys is performed.
@@ -186,9 +243,11 @@
186 243 '_metasync_canonical_url' => 'canonical',
187 244 '_metasync_focus_keyword' => 'focus_keyword',
188 245 '_metasync_breadcrumb_title' => 'breadcrumb_title',
189 246 '_metasync_robots_advanced' => '_robots_advanced_json',
190 - // OTTO volatile keys (SSR writes these even without persistence)
247 + // Internal OTTO keys. OTTO stores these on every sync regardless of
248 + // any setting; whether they may reach third-party storage is decided
249 + // by OTTO_PERSISTENCE_KEYS above.
191 250 '_metasync_otto_title' => 'title',
192 251 '_metasync_otto_description' => 'desc',
193 252 '_metasync_otto_og_title' => 'og_title',
194 253 '_metasync_otto_og_description' => 'og_desc',
@@ -203,8 +262,17 @@
203 262 if (!isset($watched[$meta_key])) {
204 263 return;
205 264 }
206 265
266 + // An internal OTTO field reaches third-party SEO storage only while its
267 + // OTTO Persistence setting is enabled. With the setting off the value
268 + // stays stored under its own key -- OTTO keeps rendering and reporting
269 + // on it -- but Yoast / Rank Math / AIOSEO are left untouched. Manually
270 + // edited MetaSync fields are not in the map and so are never gated.
271 + if (!$this->otto_persistence_allows($meta_key)) {
272 + return;
273 + }
274 +
207 275 $canonical_key = $watched[$meta_key];
208 276
209 277 // JSON key -- do a full sync + mirror to legacy meta boxes
210 278 if ($canonical_key === '_robots_advanced_json') {
@@ -215,8 +283,16 @@
215 283 if ($this->is_yoast_active()) {
216 284 $sync_instance = $this;
217 285 $sync_post_id = (int) $post_id;
218 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 + }
219 295 $sync_instance->sync_post($sync_post_id);
220 296 }, 0);
221 297 }
222 298 return;
@@ -240,8 +316,34 @@
240 316 [$canonical_key => $value]
241 317 );
242 318 }
243 319
320 + /**
321 + * Hook handler for deleted_post_meta.
322 + *
323 + * Only the legacy meta box keys are handled here. Unticking the last
324 + * checkbox in the Common Robots meta box deletes metasync_common_robots
325 + * instead of updating it, so without a delete hook the mirrored
326 + * _metasync_robots_advanced JSON kept the stale directive — and because the
327 + * JSON is the highest-priority source in the output resolver, the page went
328 + * on emitting a directive the editor had just cleared.
329 + *
330 + * Deletes of the JSON key itself are deliberately NOT routed into
331 + * on_meta_updated: that path mirrors the value back onto the legacy meta
332 + * boxes, so an empty value would wipe them.
333 + *
334 + * @param array $meta_ids Meta row IDs (unused).
335 + * @param int $post_id Post ID.
336 + * @param string $meta_key Meta key being deleted.
337 + */
338 + public function on_meta_deleted($meta_ids, $post_id, $meta_key) {
339 + if ($meta_key !== 'metasync_common_robots' && $meta_key !== 'metasync_advance_robots') {
340 + return;
341 + }
342 +
343 + $this->sync_legacy_to_json((int) $post_id);
344 + }
345 +
244 346 // ------------------------------------------------------------------
245 347 // Data collection
246 348 // ------------------------------------------------------------------
247 349
@@ -266,25 +368,102 @@
266 368 if ($fields['canonical'] === '') {
267 369 unset($fields['canonical']);
268 370 }
269 371 }
270 - return $fields;
372 +
373 + // Same reasoning for the social title/description fields: on this
374 + // fast-path the value arrives straight from the meta write, so a stored
375 + // "Auto Draft" pre-fill placeholder would be mirrored verbatim into
376 + // Yoast/RankMath/AIOSEO and emitted there as og:title. Drop it instead so
377 + // each plugin keeps whatever it already has.
378 + // @phpstan-ignore-next-line function.alreadyNarrowedType
379 + if (method_exists('Metasync_OpenGraph', 'is_auto_draft_title')) {
380 + foreach (['og_title', 'og_desc', 'twitter_title', 'twitter_desc'] as $social_field) {
381 + if (array_key_exists($social_field, $fields)
382 + && Metasync_OpenGraph::is_auto_draft_title($fields[$social_field])
383 + ) {
384 + unset($fields[$social_field]);
385 + }
386 + }
387 + }
388 +
389 + return $this->apply_feature_flags_to_payload($fields);
271 390 }
272 391
273 392 $all_meta = get_post_custom($post_id);
274 393
275 - $get = function ($key) use ($all_meta) {
394 + $get = function ($key) use ($all_meta, $post_id) {
276 395 if (!isset($all_meta[$key])) {
277 396 return '';
278 397 }
279 - return is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
398 + $value = is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
399 +
400 + // The meta box pre-fills the social title/description fields from the post
401 + // title, which is the "Auto Draft" placeholder on a brand-new post. Collapse
402 + // it to '' here so the placeholder is never mirrored into Yoast/RankMath/
403 + // AIOSEO storage, where those plugins would emit it as og:title.
404 + // Same sanitize-at-the-source placement as Metasync_Canonical_Sanitizer below.
405 + //
406 + // method_exists (not just class_exists) for the same reason
407 + // sync_layer_handles() checks: this runs on meta writes during front-end
408 + // requests, and a partially updated install can leave an older
409 + // class-metasync-opengraph.php beside this file.
410 + // @phpstan-ignore-next-line function.alreadyNarrowedType
411 + if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')
412 + && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS')
413 + && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
414 + ) {
415 + $value = Metasync_OpenGraph::strip_auto_draft_title($value);
416 + }
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 +
445 + return $value;
280 446 };
281 447
282 448 $data = [];
283 449
284 - // Helper: first non-empty value from a list of meta keys
450 + // Helper: first non-empty value from a list of meta keys.
451 + //
452 + // Internal OTTO keys are skipped while their OTTO Persistence setting is
453 + // disabled. Gating here and not only in on_meta_updated() is what makes
454 + // the setting hold for the callers that re-read meta themselves rather
455 + // than passing a resolved field: the sync at the end of OTTO SSR
456 + // processing (metasync_update_comprehensive_seo_fields), the MCP OTTO
457 + // refresh tool, and the robots full-sync branch below.
458 + //
459 + // Each chain lists the manually edited key first and the internal OTTO
460 + // key last, so a customer-entered value is unaffected and still wins.
285 461 $first = function (...$keys) use ($get) {
286 462 foreach ($keys as $key) {
463 + if (!$this->otto_persistence_allows($key)) {
464 + continue;
465 + }
287 466 $val = $get($key);
288 467 if (!empty($val)) {
289 468 return $val;
290 469 }
@@ -291,13 +470,20 @@
291 470 }
292 471 return '';
293 472 };
294 473
295 - // title: sidebar > persisted OTTO > volatile OTTO
296 - $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));
297 484
298 - // desc: sidebar > persisted OTTO > volatile OTTO
299 - $data['desc'] = $first('_metasync_seo_desc', '_metasync_metadesc', '_metasync_otto_description');
485 + $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION));
300 486
301 487 // Robots directives: check _metasync_robots_advanced JSON first,
302 488 // then fall back to metasync_common_robots array + metasync_advance_robots array
303 489 $robots_json_raw = $get('_metasync_robots_advanced');
@@ -303,9 +489,13 @@
303 489 $robots_json_raw = $get('_metasync_robots_advanced');
304 490 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
305 491
306 492 if (is_array($robots_json)) {
307 - $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 + );
308 498 $data['nofollow'] = !empty($robots_json['nofollow']);
309 499 $data['noarchive'] = !empty($robots_json['noarchive']);
310 500 $data['nosnippet'] = !empty($robots_json['nosnippet']);
311 501 $data['noimageindex'] = !empty($robots_json['noimageindex']);
@@ -312,11 +502,15 @@
312 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);
313 503 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
314 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);
315 505 } else {
316 - // noindex from dedicated key
317 - $robots_index = $get('_metasync_robots_index');
318 - $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 + );
319 513
320 514 // Common robots array (serialized)
321 515 $common_raw = $get('metasync_common_robots');
322 516 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
@@ -368,11 +562,90 @@
368 562 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
369 563 $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
370 564 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
371 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 +
372 615 return $data;
373 616 }
374 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 +
375 648 // ------------------------------------------------------------------
376 649 // Plugin detectors
377 650 // ------------------------------------------------------------------
378 651
@@ -422,35 +695,85 @@
422 695 // Per-plugin sync
423 696 // ------------------------------------------------------------------
424 697
425 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 + /**
426 731 * Mirror canonical data into Yoast post meta and indexable cache.
427 732 *
428 733 * @param int $post_id Post ID.
429 734 * @param array $data Canonical key/value pairs.
430 - * @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.
431 748 */
432 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 +
433 756 // title
434 757 if (!empty($data['title'])) {
435 - 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']);
436 759 }
437 760
438 761 // description -- strip newlines first
439 762 if (!empty($data['desc'])) {
440 763 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
441 - update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
764 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
442 765 }
443 766
444 767 // noindex: '0'=default, '1'=noindex, '2'=index
445 768 if (array_key_exists('noindex', $data)) {
446 769 $val = $data['noindex'] ? '1' : '2';
447 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
770 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
448 771 }
449 772
450 773 // nofollow: '0'=follow, '1'=nofollow
451 774 if (array_key_exists('nofollow', $data)) {
452 - 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');
453 776 }
454 777
455 778 // advanced robots: comma-separated NO spaces
456 779 $adv = [];
@@ -462,38 +785,41 @@
462 785 }
463 786 if (!empty($data['noimageindex'])) {
464 787 $adv[] = 'noimageindex';
465 788 }
466 - 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));
467 793
468 794 // OG
469 795 if (!empty($data['og_title'])) {
470 - 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;
471 797 }
472 798 if (!empty($data['og_desc'])) {
473 - 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;
474 800 }
475 801 if (!empty($data['og_image'])) {
476 - 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;
477 803 }
478 804
479 805 // Twitter
480 806 if (!empty($data['twitter_title'])) {
481 - 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;
482 808 }
483 809 if (!empty($data['twitter_desc'])) {
484 - 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;
485 811 }
486 812
487 813 // Canonical, focus keyword, breadcrumb
488 814 if (!empty($data['canonical'])) {
489 - 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;
490 816 }
491 817 if (!empty($data['focus_keyword'])) {
492 - 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;
493 819 }
494 820 if (!empty($data['breadcrumb_title'])) {
495 - 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;
496 822 }
497 823
498 824 // Update wp_yoast_indexable cache row for immediate effect
499 825 global $wpdb;
@@ -531,9 +857,9 @@
531 857 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
532 858 }
533 859 if (!empty($data['twitter_card'])) {
534 860 // twitter_card column only exists in newer Yoast versions; skip if absent
535 - $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
536 862 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
537 863 DB_NAME,
538 864 $indexable_table
539 865 ));
@@ -551,15 +877,38 @@
551 877 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
552 878 }
553 879
554 880 if (!empty($updates)) {
555 - $row_exists = $wpdb->get_var($wpdb->prepare(
556 - "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'",
557 883 $post_id
558 884 ));
559 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 +
560 909 if ($row_exists) {
561 - $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
562 911 $indexable_table,
563 912 $updates,
564 913 ['object_id' => $post_id, 'object_type' => 'post']
565 914 );
@@ -579,12 +928,139 @@
579 928 'is_cornerstone' => 0,
580 929 'created_at' => current_time('mysql'),
581 930 'updated_at' => current_time('mysql'),
582 931 ], $updates);
583 - $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 + }
584 952 }
585 953 }
586 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 +
587 1063 return true;
588 1064 }
589 1065
590 1066 /**
@@ -591,17 +1067,25 @@
591 1067 * Mirror canonical data into Rank Math post meta.
592 1068 *
593 1069 * @param int $post_id Post ID.
594 1070 * @param array $data Canonical key/value pairs.
595 - * @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().
596 1074 */
597 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 +
598 1082 // title, desc
599 1083 if (!empty($data['title'])) {
600 - update_post_meta($post_id, 'rank_math_title', $data['title']);
1084 + $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
601 1085 }
602 1086 if (!empty($data['desc'])) {
603 - update_post_meta($post_id, 'rank_math_description', $data['desc']);
1087 + $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
604 1088 }
605 1089
606 1090 // robots: PHP indexed array
607 1091 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
@@ -613,9 +1097,9 @@
613 1097 }
614 1098 if (array_key_exists('nofollow', $data)) {
615 1099 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
616 1100 }
617 - 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)));
618 1102 }
619 1103
620 1104 // Advanced robots: max-* go into rank_math_advanced_robots
621 1105 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
@@ -643,9 +1127,9 @@
643 1127 $val = (int) $data['max_video_preview'];
644 1128 $adv['max-video-preview'] = (string) $val;
645 1129 }
646 1130 if (!empty($adv)) {
647 - update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
1131 + $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
648 1132 }
649 1133 }
650 1134
651 1135 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
@@ -660,51 +1144,164 @@
660 1144 if (!empty($data[$dir])) {
661 1145 $robots[] = $dir;
662 1146 }
663 1147 }
664 - 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)));
665 1149 }
666 1150
667 1151 // OG
668 1152 if (!empty($data['og_title'])) {
669 - 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;
670 1154 }
671 1155 if (!empty($data['og_desc'])) {
672 - 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;
673 1157 }
674 1158 if (!empty($data['og_image'])) {
675 - 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;
676 1160 $img_id = attachment_url_to_postid($data['og_image']);
677 1161 if ($img_id) {
678 - 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;
679 1163 }
680 1164 }
681 1165
682 1166 // Twitter
683 1167 if (!empty($data['twitter_title'])) {
684 - 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;
685 1169 }
686 1170 if (!empty($data['twitter_desc'])) {
687 - 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;
688 1172 }
689 1173 if (!empty($data['twitter_card'])) {
690 1174 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
691 1175 if (in_array($data['twitter_card'], $valid_cards, true)) {
692 - 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;
693 1177 }
694 1178 }
695 1179
696 1180 // Canonical, focus keyword, breadcrumb
697 1181 if (!empty($data['canonical'])) {
698 - 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;
699 1183 }
700 1184 if (!empty($data['focus_keyword'])) {
701 - 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;
702 1186 }
703 1187 if (!empty($data['breadcrumb_title'])) {
704 - 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;
705 1189 }
706 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 +
707 1304 return true;
708 1305 }
709 1306
710 1307 /**
@@ -711,54 +1308,73 @@
711 1308 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
712 1309 *
713 1310 * @param int $post_id Post ID.
714 1311 * @param array $data Canonical key/value pairs.
715 - * @return bool True when the row was written, false when the table is
716 - * 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.
717 1317 */
718 1318 private function sync_aioseo($post_id, array $data) {
719 1319 global $wpdb;
720 1320
1321 + if (!$this->third_party_writes_allowed()) {
1322 + return false;
1323 + }
1324 +
721 1325 $table = $wpdb->prefix . 'aioseo_posts';
722 1326
723 1327 // Bail if the AIOSEO post table does not exist (plugin not initialised).
724 - $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
725 1329 if ($table_exists !== $table) {
726 1330 return false;
727 1331 }
728 1332
729 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;
730 1337
731 1338 if (!empty($data['title'])) {
732 1339 $row['title'] = sanitize_text_field($data['title']);
1340 + $wrote = true;
733 1341 }
734 1342 if (!empty($data['desc'])) {
735 1343 $row['description'] = sanitize_text_field($data['desc']);
1344 + $wrote = true;
736 1345 }
737 1346 if (!empty($data['og_title'])) {
738 1347 $row['og_title'] = sanitize_text_field($data['og_title']);
1348 + $wrote = true;
739 1349 }
740 1350 if (!empty($data['og_desc'])) {
741 1351 $row['og_description'] = sanitize_text_field($data['og_desc']);
1352 + $wrote = true;
742 1353 }
743 1354 if (!empty($data['og_image'])) {
744 1355 $row['og_image_type'] = 'custom';
745 1356 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
1357 + $wrote = true;
746 1358 }
747 1359 if (!empty($data['twitter_title'])) {
748 1360 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
1361 + $wrote = true;
749 1362 }
750 1363 if (!empty($data['twitter_desc'])) {
751 1364 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
1365 + $wrote = true;
752 1366 }
753 1367 if (!empty($data['twitter_card'])) {
754 1368 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
755 1369 if (in_array($data['twitter_card'], $valid_cards, true)) {
756 1370 $row['twitter_card'] = $data['twitter_card'];
1371 + $wrote = true;
757 1372 }
758 1373 }
759 1374 if (!empty($data['canonical'])) {
760 1375 $row['canonical_url'] = esc_url_raw($data['canonical']);
1376 + $wrote = true;
761 1377 }
762 1378
763 1379 // focus keyword as keyphrases JSON
764 1380 if (!empty($data['focus_keyword'])) {
@@ -769,8 +1385,9 @@
769 1385 'analysis' => new \stdClass(),
770 1386 ],
771 1387 'additional' => [],
772 1388 ]);
1389 + $wrote = true;
773 1390 }
774 1391
775 1392 // Robots
776 1393 $has_robots = false;
@@ -816,17 +1433,38 @@
816 1433 }
817 1434
818 1435 $row['updated'] = current_time('mysql');
819 1436
820 - $existing_id = $wpdb->get_var($wpdb->prepare(
821 - "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",
822 1439 $post_id
823 1440 ));
824 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.
825 1453 if ($existing_id) {
826 - 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
827 1460 }
828 1461
1462 + $backups_created = [];
1463 + if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1464 + return false;
1465 + }
1466 +
829 1467 // New row -- must include all NOT NULL columns with no defaults
830 1468 $row['post_id'] = $post_id;
831 1469 $row['created'] = current_time('mysql');
832 1470 $robot_defaults = [
@@ -840,9 +1478,23 @@
840 1478 'robots_notranslate' => 0,
841 1479 ];
842 1480 $row = array_merge($robot_defaults, $row);
843 1481
844 - 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;
845 1497 }
846 1498
847 1499 // ------------------------------------------------------------------
848 1500 // Two-way sync: sidebar JSON ↔ legacy meta boxes