PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 All 139 releases
← All changes | includes/class-metasync-plugin-sync.php +734 -54 2.6.17trunk 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 */
@@ -33,8 +37,22 @@
33 37 */
34 38 private $syncing_json_to_legacy = [];
35 39
36 40 /**
41 + * Guard: tracks post IDs currently being synced from legacy → JSON.
42 + *
43 + * Prevents sync_to_legacy_meta from firing in response to the JSON write
44 + * that sync_legacy_to_json just made. Without this the round trip is
45 + * lossy: the JSON format cannot distinguish "user explicitly chose the
46 + * default" from "nothing set" (the sidebar always emits max_snippet=-1
47 + * and max_image_preview=large), so the mirror back would discard the
48 + * legacy meta box values it had just derived the JSON from.
49 + *
50 + * @var array
51 + */
52 + private $syncing_legacy_to_json = [];
53 +
54 + /**
37 55 * MetaSync meta keys that trigger a sync when written.
38 56 *
39 57 * @var array
40 58 */
@@ -65,8 +83,34 @@
65 83 '_metasync_otto_keywords',
66 84 ];
67 85
68 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 + /**
69 113 * Get singleton instance.
70 114 *
71 115 * @return self
72 116 */
@@ -77,8 +121,35 @@
77 121 return self::$instance;
78 122 }
79 123
80 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 + /**
81 152 * Sync MetaSync post meta to every active SEO plugin.
82 153 *
83 154 * When $fields is non-empty only those canonical keys are synced.
84 155 * When empty a full sync of all canonical keys is performed.
@@ -172,9 +243,11 @@
172 243 '_metasync_canonical_url' => 'canonical',
173 244 '_metasync_focus_keyword' => 'focus_keyword',
174 245 '_metasync_breadcrumb_title' => 'breadcrumb_title',
175 246 '_metasync_robots_advanced' => '_robots_advanced_json',
176 - // 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.
177 250 '_metasync_otto_title' => 'title',
178 251 '_metasync_otto_description' => 'desc',
179 252 '_metasync_otto_og_title' => 'og_title',
180 253 '_metasync_otto_og_description' => 'og_desc',
@@ -189,8 +262,17 @@
189 262 if (!isset($watched[$meta_key])) {
190 263 return;
191 264 }
192 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 +
193 275 $canonical_key = $watched[$meta_key];
194 276
195 277 // JSON key -- do a full sync + mirror to legacy meta boxes
196 278 if ($canonical_key === '_robots_advanced_json') {
@@ -201,8 +283,16 @@
201 283 if ($this->is_yoast_active()) {
202 284 $sync_instance = $this;
203 285 $sync_post_id = (int) $post_id;
204 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 + }
205 295 $sync_instance->sync_post($sync_post_id);
206 296 }, 0);
207 297 }
208 298 return;
@@ -226,8 +316,34 @@
226 316 [$canonical_key => $value]
227 317 );
228 318 }
229 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 +
230 346 // ------------------------------------------------------------------
231 347 // Data collection
232 348 // ------------------------------------------------------------------
233 349
@@ -252,25 +368,102 @@
252 368 if ($fields['canonical'] === '') {
253 369 unset($fields['canonical']);
254 370 }
255 371 }
256 - 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);
257 390 }
258 391
259 392 $all_meta = get_post_custom($post_id);
260 393
261 - $get = function ($key) use ($all_meta) {
394 + $get = function ($key) use ($all_meta, $post_id) {
262 395 if (!isset($all_meta[$key])) {
263 396 return '';
264 397 }
265 - 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;
266 446 };
267 447
268 448 $data = [];
269 449
270 - // 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.
271 461 $first = function (...$keys) use ($get) {
272 462 foreach ($keys as $key) {
463 + if (!$this->otto_persistence_allows($key)) {
464 + continue;
465 + }
273 466 $val = $get($key);
274 467 if (!empty($val)) {
275 468 return $val;
276 469 }
@@ -277,13 +470,20 @@
277 470 }
278 471 return '';
279 472 };
280 473
281 - // title: sidebar > persisted OTTO > volatile OTTO
282 - $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));
283 484
284 - // desc: sidebar > persisted OTTO > volatile OTTO
285 - $data['desc'] = $first('_metasync_seo_desc', '_metasync_metadesc', '_metasync_otto_description');
485 + $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION));
286 486
287 487 // Robots directives: check _metasync_robots_advanced JSON first,
288 488 // then fall back to metasync_common_robots array + metasync_advance_robots array
289 489 $robots_json_raw = $get('_metasync_robots_advanced');
@@ -289,9 +489,13 @@
289 489 $robots_json_raw = $get('_metasync_robots_advanced');
290 490 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
291 491
292 492 if (is_array($robots_json)) {
293 - $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 + );
294 498 $data['nofollow'] = !empty($robots_json['nofollow']);
295 499 $data['noarchive'] = !empty($robots_json['noarchive']);
296 500 $data['nosnippet'] = !empty($robots_json['nosnippet']);
297 501 $data['noimageindex'] = !empty($robots_json['noimageindex']);
@@ -298,11 +502,15 @@
298 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);
299 503 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
300 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);
301 505 } else {
302 - // noindex from dedicated key
303 - $robots_index = $get('_metasync_robots_index');
304 - $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 + );
305 513
306 514 // Common robots array (serialized)
307 515 $common_raw = $get('metasync_common_robots');
308 516 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
@@ -321,11 +529,22 @@
321 529 if (!is_array($adv_robots)) {
322 530 $adv_robots = [];
323 531 }
324 532
325 - $data['max_snippet'] = isset($adv_robots['max-snippet']) ? (int) $adv_robots['max-snippet'] : null;
326 - $data['max_image_preview'] = isset($adv_robots['max-image-preview']) ? $adv_robots['max-image-preview'] : null;
327 - $data['max_video_preview'] = isset($adv_robots['max-video-preview']) ? (int) $adv_robots['max-video-preview'] : null;
533 + // Each advance-robots directive is stored as ['enable' => .., 'length' => ..].
534 + // Read the length scalar (honouring the enable flag) instead of casting the
535 + // whole sub-array — (int) of a non-empty array is 1, which is what produced the
536 + // "max-snippet:1" instead of "-1" and dropped the image-preview value. Mirrors
537 + // sync_legacy_to_json() so both directions read the legacy format identically.
538 + $data['max_snippet'] = !empty($adv_robots['max-snippet']['enable'])
539 + ? (isset($adv_robots['max-snippet']['length']) ? (int) $adv_robots['max-snippet']['length'] : -1)
540 + : null;
541 + $data['max_image_preview'] = !empty($adv_robots['max-image-preview']['enable'])
542 + ? (isset($adv_robots['max-image-preview']['length']) ? (string) $adv_robots['max-image-preview']['length'] : 'large')
543 + : null;
544 + $data['max_video_preview'] = !empty($adv_robots['max-video-preview']['enable'])
545 + ? (isset($adv_robots['max-video-preview']['length']) ? (int) $adv_robots['max-video-preview']['length'] : -1)
546 + : null;
328 547 }
329 548
330 549 // Social / OG: persisted > volatile OTTO
331 550 $data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title');
@@ -343,11 +562,90 @@
343 562 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
344 563 $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
345 564 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
346 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 +
347 615 return $data;
348 616 }
349 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 +
350 648 // ------------------------------------------------------------------
351 649 // Plugin detectors
352 650 // ------------------------------------------------------------------
353 651
@@ -397,35 +695,85 @@
397 695 // Per-plugin sync
398 696 // ------------------------------------------------------------------
399 697
400 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 + /**
401 731 * Mirror canonical data into Yoast post meta and indexable cache.
402 732 *
403 733 * @param int $post_id Post ID.
404 734 * @param array $data Canonical key/value pairs.
405 - * @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.
406 748 */
407 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 +
408 756 // title
409 757 if (!empty($data['title'])) {
410 - 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']);
411 759 }
412 760
413 761 // description -- strip newlines first
414 762 if (!empty($data['desc'])) {
415 763 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
416 - update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
764 + $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
417 765 }
418 766
419 767 // noindex: '0'=default, '1'=noindex, '2'=index
420 768 if (array_key_exists('noindex', $data)) {
421 769 $val = $data['noindex'] ? '1' : '2';
422 - update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
770 + $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
423 771 }
424 772
425 773 // nofollow: '0'=follow, '1'=nofollow
426 774 if (array_key_exists('nofollow', $data)) {
427 - 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');
428 776 }
429 777
430 778 // advanced robots: comma-separated NO spaces
431 779 $adv = [];
@@ -437,38 +785,41 @@
437 785 }
438 786 if (!empty($data['noimageindex'])) {
439 787 $adv[] = 'noimageindex';
440 788 }
441 - 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));
442 793
443 794 // OG
444 795 if (!empty($data['og_title'])) {
445 - 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;
446 797 }
447 798 if (!empty($data['og_desc'])) {
448 - 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;
449 800 }
450 801 if (!empty($data['og_image'])) {
451 - 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;
452 803 }
453 804
454 805 // Twitter
455 806 if (!empty($data['twitter_title'])) {
456 - 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;
457 808 }
458 809 if (!empty($data['twitter_desc'])) {
459 - 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;
460 811 }
461 812
462 813 // Canonical, focus keyword, breadcrumb
463 814 if (!empty($data['canonical'])) {
464 - 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;
465 816 }
466 817 if (!empty($data['focus_keyword'])) {
467 - 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;
468 819 }
469 820 if (!empty($data['breadcrumb_title'])) {
470 - 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;
471 822 }
472 823
473 824 // Update wp_yoast_indexable cache row for immediate effect
474 825 global $wpdb;
@@ -531,8 +882,31 @@
531 882 "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
532 883 $post_id
533 884 ));
534 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 +
535 909 if ($row_exists) {
536 910 $wpdb->update(
537 911 $indexable_table,
538 912 $updates,
@@ -554,12 +928,137 @@
554 928 'is_cornerstone' => 0,
555 929 'created_at' => current_time('mysql'),
556 930 'updated_at' => current_time('mysql'),
557 931 ], $updates);
558 - $wpdb->insert($indexable_table, $insert);
932 +
933 + // The row_existed='0' marker was recorded before this insert,
934 + // because a marker that will not save has to be able to veto the
935 + // write. A failed insert means no row of ours exists, and a
936 + // marker left saying otherwise would let a restore delete a row
937 + // Yoast or the customer creates afterwards. The per-column
938 + // backups recorded beside it go too: they are write-once, so a
939 + // column captured as NULL because there was no row would stay
940 + // NULL for good and blank a real value the customer later puts
941 + // in that row.
942 + //
943 + // The post meta above has already been written, so the return
944 + // still reports what landed. Claiming nothing was written would
945 + // tell the conflict handler this post is unsynced to Yoast while
946 + // Yoast's own meta holds our values, and it would hand tag
947 + // ownership to the wrong plugin.
948 + if ($wpdb->insert($indexable_table, $insert) === false) {
949 + Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
950 + return $wrote;
951 + }
559 952 }
560 953 }
561 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 + $current = $wpdb->get_row(
1011 + $wpdb->prepare(
1012 + "SELECT {$select} FROM {$table} WHERE object_id = %d AND object_type = 'post'",
1013 + $post_id
1014 + ),
1015 + ARRAY_A
1016 + );
1017 +
1018 + // The row was there a moment ago, so a null answer now is a failed
1019 + // read, not an empty row. Recording it as "every column was NULL"
1020 + // would tell a later restore to delete values it should put back,
1021 + // which is the exact loss this layer exists to prevent. No write is
1022 + // happening, so the marker recorded above has to go too — a marker
1023 + // left describing a write that never ran is a stale story.
1024 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1025 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1026 + return false;
1027 + }
1028 + }
1029 +
1030 + foreach ($columns as $column) {
1031 + // A missing row and a NULL column are the same thing to a restore:
1032 + // there was no value here, so put nothing back.
1033 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1034 +
1035 + $field = 'yoast_indexable_' . $column;
1036 +
1037 + // One unsaved column is enough to refuse the whole write: a
1038 + // half-original, half-OTTO row is something no restore can unpick.
1039 + // The refusal also means the caller writes nothing, so withdraw the
1040 + // marker and the columns recorded so far — the same rule as the
1041 + // failed-insert path in sync_yoast(). Left behind, a row_existed='0'
1042 + // marker would let a restore delete a row the customer creates
1043 + // later, and a NULL column backup would blank a real value in it.
1044 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1045 + 'post',
1046 + $post_id,
1047 + $field,
1048 + $updates[$column],
1049 + $current_value,
1050 + $column_created
1051 + )) {
1052 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1053 + return false;
1054 + }
1055 +
1056 + if ($column_created) {
1057 + $created[] = $field;
1058 + }
1059 + }
1060 +
562 1061 return true;
563 1062 }
564 1063
565 1064 /**
@@ -566,17 +1065,25 @@
566 1065 * Mirror canonical data into Rank Math post meta.
567 1066 *
568 1067 * @param int $post_id Post ID.
569 1068 * @param array $data Canonical key/value pairs.
570 - * @return bool True once dispatch completes.
1069 + * @return bool True when at least one value-bearing field was mirrored,
1070 + * false when the payload carried nothing to write. Robots
1071 + * directives are excluded for the reason given on sync_yoast().
571 1072 */
572 1073 private function sync_rankmath($post_id, array $data) {
1074 + if (!$this->third_party_writes_allowed()) {
1075 + return false;
1076 + }
1077 +
1078 + $wrote = false;
1079 +
573 1080 // title, desc
574 1081 if (!empty($data['title'])) {
575 - update_post_meta($post_id, 'rank_math_title', $data['title']);
1082 + $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
576 1083 }
577 1084 if (!empty($data['desc'])) {
578 - update_post_meta($post_id, 'rank_math_description', $data['desc']);
1085 + $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
579 1086 }
580 1087
581 1088 // robots: PHP indexed array
582 1089 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
@@ -588,9 +1095,9 @@
588 1095 }
589 1096 if (array_key_exists('nofollow', $data)) {
590 1097 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
591 1098 }
592 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1099 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
593 1100 }
594 1101
595 1102 // Advanced robots: max-* go into rank_math_advanced_robots
596 1103 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
@@ -605,22 +1112,22 @@
605 1112 $existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true);
606 1113 $adv = is_array($existing_adv) ? $existing_adv : [];
607 1114 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
608 1115 $val = (int) $data['max_snippet'];
609 - $adv['max-snippet'] = 'max-snippet:' . $val;
1116 + $adv['max-snippet'] = (string) $val;
610 1117 }
611 1118 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
612 1119 $allowed = ['none', 'standard', 'large'];
613 1120 if (in_array($data['max_image_preview'], $allowed, true)) {
614 - $adv['max-image-preview'] = 'max-image-preview:' . $data['max_image_preview'];
1121 + $adv['max-image-preview'] = (string) $data['max_image_preview'];
615 1122 }
616 1123 }
617 1124 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
618 1125 $val = (int) $data['max_video_preview'];
619 - $adv['max-video-preview'] = 'max-video-preview:' . $val;
1126 + $adv['max-video-preview'] = (string) $val;
620 1127 }
621 1128 if (!empty($adv)) {
622 - update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
1129 + $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
623 1130 }
624 1131 }
625 1132
626 1133 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
@@ -635,51 +1142,159 @@
635 1142 if (!empty($data[$dir])) {
636 1143 $robots[] = $dir;
637 1144 }
638 1145 }
639 - update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1146 + $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
640 1147 }
641 1148
642 1149 // OG
643 1150 if (!empty($data['og_title'])) {
644 - update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
1151 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
645 1152 }
646 1153 if (!empty($data['og_desc'])) {
647 - update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
1154 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
648 1155 }
649 1156 if (!empty($data['og_image'])) {
650 - update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
1157 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])) || $wrote;
651 1158 $img_id = attachment_url_to_postid($data['og_image']);
652 1159 if ($img_id) {
653 - update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
1160 + $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image_id', $img_id) || $wrote;
654 1161 }
655 1162 }
656 1163
657 1164 // Twitter
658 1165 if (!empty($data['twitter_title'])) {
659 - update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
1166 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
660 1167 }
661 1168 if (!empty($data['twitter_desc'])) {
662 - update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
1169 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
663 1170 }
664 1171 if (!empty($data['twitter_card'])) {
665 1172 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
666 1173 if (in_array($data['twitter_card'], $valid_cards, true)) {
667 - update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
1174 + $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
668 1175 }
669 1176 }
670 1177
671 1178 // Canonical, focus keyword, breadcrumb
672 1179 if (!empty($data['canonical'])) {
673 - update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
1180 + $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
674 1181 }
675 1182 if (!empty($data['focus_keyword'])) {
676 - update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
1183 + $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
677 1184 }
678 1185 if (!empty($data['breadcrumb_title'])) {
679 - update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
1186 + $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
680 1187 }
681 1188
1189 + return $wrote;
1190 + }
1191 +
1192 + /**
1193 + * Preserve the AIOSEO columns a sync is about to overwrite.
1194 + *
1195 + * AIOSEO keeps post SEO data in its own `aioseo_posts` table rather than in
1196 + * post meta, so there is no meta row to save and no field a restore could
1197 + * delete. Two things are recorded instead, both as post meta on the post
1198 + * itself:
1199 + *
1200 + * - one backup per column being written, so a restore can put the original
1201 + * values back and leave every other column of the user's row alone;
1202 + * - whether the row existed at all, so a restore can delete a row that only
1203 + * exists because we created it instead of leaving an empty shell behind.
1204 + *
1205 + * @param int $post_id Post ID.
1206 + * @param string $table Fully prefixed AIOSEO table name.
1207 + * @param array $row Columns and values about to be written.
1208 + * @param bool $row_existed Whether AIOSEO already had a row for this post.
1209 + * @param array $created Out-param, filled with the backup fields this
1210 + * call created, so a failed write can withdraw
1211 + * exactly its own rows and no one else's.
1212 + * @return bool True when every original was preserved and the caller may write.
1213 + */
1214 + private function backup_aioseo_columns($post_id, $table, array $row, $row_existed, array &$created) {
1215 + $created = [];
1216 +
1217 + if (!class_exists('Metasync_Seo_Backup')) {
1218 + return false;
1219 + }
1220 +
1221 + global $wpdb;
1222 +
1223 + // Whether the row pre-existed is what a restore uses to choose between
1224 + // putting the original columns back and deleting a row that only exists
1225 + // because we made it. A marker that will not record is as disqualifying
1226 + // as a column that will not.
1227 + if (!Metasync_Seo_Backup::record_marker(
1228 + 'post',
1229 + $post_id,
1230 + 'aioseo_row_existed',
1231 + $row_existed ? '1' : '0',
1232 + $marker_created
1233 + )) {
1234 + return false;
1235 + }
1236 +
1237 + if ($marker_created) {
1238 + $created[] = 'aioseo_row_existed';
1239 + }
1240 +
1241 + $columns = array_diff(array_keys($row), ['updated', 'created', 'post_id']);
1242 + if (empty($columns)) {
1243 + return true;
1244 + }
1245 +
1246 + $current = null;
1247 + if ($row_existed) {
1248 + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1249 + $current = $wpdb->get_row(
1250 + $wpdb->prepare("SELECT {$select} FROM {$table} WHERE post_id = %d", $post_id),
1251 + ARRAY_A
1252 + );
1253 +
1254 + // The row was there a moment ago, so a null answer now is a failed
1255 + // read, not an empty row. Recording it as "every column was NULL"
1256 + // would tell a later restore to delete values it should put back,
1257 + // which is the exact loss this layer exists to prevent. No write is
1258 + // happening, so the marker recorded above has to go too — a marker
1259 + // left describing a write that never ran is a stale story.
1260 + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1261 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1262 + return false;
1263 + }
1264 + }
1265 +
1266 + foreach ($columns as $column) {
1267 + // A missing row and a NULL column are the same thing to a restore:
1268 + // there was no value here, so put nothing back.
1269 + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1270 +
1271 + $field = 'aioseo_' . $column;
1272 +
1273 + // One unsaved column is enough to refuse the whole write: a half-original,
1274 + // half-OTTO row is something no restore can unpick. The refusal also
1275 + // means the caller writes nothing, so withdraw the marker and the
1276 + // columns recorded so far — the same rule as the failed-insert path
1277 + // in sync_aioseo(). Left behind, a row_existed='0' marker would let
1278 + // a restore delete a row the customer creates later, and a NULL
1279 + // column backup would blank a real value in it.
1280 + if (!Metasync_Seo_Backup::backup_before_overwrite(
1281 + 'post',
1282 + $post_id,
1283 + $field,
1284 + $row[$column],
1285 + $current_value,
1286 + $column_created
1287 + )) {
1288 + Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1289 + return false;
1290 + }
1291 +
1292 + if ($column_created) {
1293 + $created[] = $field;
1294 + }
1295 + }
1296 +
682 1297 return true;
683 1298 }
684 1299
685 1300 /**
@@ -686,14 +1301,21 @@
686 1301 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
687 1302 *
688 1303 * @param int $post_id Post ID.
689 1304 * @param array $data Canonical key/value pairs.
690 - * @return bool True when the row was written, false when the table is
691 - * missing or the write failed.
1305 + * @return bool True when at least one value-bearing field was written,
1306 + * false when the table is missing, the write failed, or the
1307 + * payload carried only robots directives. Robots are excluded
1308 + * for the reason given on sync_yoast(), so the receipt means
1309 + * the same thing for all three plugins.
692 1310 */
693 1311 private function sync_aioseo($post_id, array $data) {
694 1312 global $wpdb;
695 1313
1314 + if (!$this->third_party_writes_allowed()) {
1315 + return false;
1316 + }
1317 +
696 1318 $table = $wpdb->prefix . 'aioseo_posts';
697 1319
698 1320 // Bail if the AIOSEO post table does not exist (plugin not initialised).
699 1321 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
@@ -701,39 +1323,51 @@
701 1323 return false;
702 1324 }
703 1325
704 1326 $row = [];
1327 + // Robots fill $row too, so a separate flag is needed: the row is still
1328 + // written for a robots-only payload, it just is not a content sync.
1329 + $wrote = false;
705 1330
706 1331 if (!empty($data['title'])) {
707 1332 $row['title'] = sanitize_text_field($data['title']);
1333 + $wrote = true;
708 1334 }
709 1335 if (!empty($data['desc'])) {
710 1336 $row['description'] = sanitize_text_field($data['desc']);
1337 + $wrote = true;
711 1338 }
712 1339 if (!empty($data['og_title'])) {
713 1340 $row['og_title'] = sanitize_text_field($data['og_title']);
1341 + $wrote = true;
714 1342 }
715 1343 if (!empty($data['og_desc'])) {
716 1344 $row['og_description'] = sanitize_text_field($data['og_desc']);
1345 + $wrote = true;
717 1346 }
718 1347 if (!empty($data['og_image'])) {
719 1348 $row['og_image_type'] = 'custom';
720 1349 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
1350 + $wrote = true;
721 1351 }
722 1352 if (!empty($data['twitter_title'])) {
723 1353 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
1354 + $wrote = true;
724 1355 }
725 1356 if (!empty($data['twitter_desc'])) {
726 1357 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
1358 + $wrote = true;
727 1359 }
728 1360 if (!empty($data['twitter_card'])) {
729 1361 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
730 1362 if (in_array($data['twitter_card'], $valid_cards, true)) {
731 1363 $row['twitter_card'] = $data['twitter_card'];
1364 + $wrote = true;
732 1365 }
733 1366 }
734 1367 if (!empty($data['canonical'])) {
735 1368 $row['canonical_url'] = esc_url_raw($data['canonical']);
1369 + $wrote = true;
736 1370 }
737 1371
738 1372 // focus keyword as keyphrases JSON
739 1373 if (!empty($data['focus_keyword'])) {
@@ -744,8 +1378,9 @@
744 1378 'analysis' => new \stdClass(),
745 1379 ],
746 1380 'additional' => [],
747 1381 ]);
1382 + $wrote = true;
748 1383 }
749 1384
750 1385 // Robots
751 1386 $has_robots = false;
@@ -796,12 +1431,33 @@
796 1431 "SELECT id FROM {$table} WHERE post_id = %d",
797 1432 $post_id
798 1433 ));
799 1434
1435 + // An unreadable probe cannot be treated as "no row". It would commit a
1436 + // write-once row_existed='0' for a row AIOSEO really has -- which a
1437 + // restore reads as licence to delete it -- and send an INSERT at a row
1438 + // that already exists. Leave AIOSEO's row alone and let the next sync
1439 + // record the truth.
1440 + if (!Metasync_Seo_Backup::db_read_succeeded()) {
1441 + return false;
1442 + }
1443 +
1444 + // No original saved means no write. Overwriting anyway is the data loss
1445 + // this whole layer exists to prevent.
800 1446 if ($existing_id) {
801 - return $wpdb->update($table, $row, ['post_id' => $post_id]) !== false;
1447 + $backups_created = [];
1448 + if (!$this->backup_aioseo_columns($post_id, $table, $row, true, $backups_created)) {
1449 + return false;
1450 + }
1451 +
1452 + return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote;
802 1453 }
803 1454
1455 + $backups_created = [];
1456 + if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1457 + return false;
1458 + }
1459 +
804 1460 // New row -- must include all NOT NULL columns with no defaults
805 1461 $row['post_id'] = $post_id;
806 1462 $row['created'] = current_time('mysql');
807 1463 $robot_defaults = [
@@ -815,9 +1471,23 @@
815 1471 'robots_notranslate' => 0,
816 1472 ];
817 1473 $row = array_merge($robot_defaults, $row);
818 1474
819 - return $wpdb->insert($table, $row) !== false;
1475 + $inserted = $wpdb->insert($table, $row);
1476 +
1477 + // The row_existed='0' marker was recorded before the insert, because a
1478 + // marker that will not save has to be able to veto the write. If the
1479 + // insert then failed there is no row of ours, and leaving the marker
1480 + // behind would let a restore delete a row the customer creates later.
1481 + // The per-column backups beside it go too, or a column captured as NULL
1482 + // for a row that never existed would blank a real value the customer
1483 + // later puts in one.
1484 + if ($inserted === false) {
1485 + Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
1486 + return false;
1487 + }
1488 +
1489 + return $wrote;
820 1490 }
821 1491
822 1492 // ------------------------------------------------------------------
823 1493 // Two-way sync: sidebar JSON ↔ legacy meta boxes
@@ -836,8 +1506,15 @@
836 1506 static $syncing_legacy = [];
837 1507 if (!empty($syncing_legacy[$post_id])) {
838 1508 return;
839 1509 }
1510 + // Our own sync_legacy_to_json() write triggered this. The legacy meta
1511 + // box values are the source of truth in that direction, and mirroring
1512 + // back would drop any value this function treats as a default (-1 /
1513 + // large), so bail out and leave the legacy keys alone.
1514 + if (!empty($this->syncing_legacy_to_json[$post_id])) {
1515 + return;
1516 + }
840 1517 $syncing_legacy[$post_id] = true;
841 1518 // Block sync_legacy_to_json from running while we write legacy keys
842 1519 $this->syncing_json_to_legacy[$post_id] = true;
843 1520
@@ -904,8 +1581,10 @@
904 1581 if (!empty($syncing_json[$post_id])) {
905 1582 return;
906 1583 }
907 1584 $syncing_json[$post_id] = true;
1585 + // Block sync_to_legacy_meta from reacting to the JSON write below
1586 + $this->syncing_legacy_to_json[$post_id] = true;
908 1587
909 1588 try {
910 1589 $common = get_post_meta($post_id, 'metasync_common_robots', true);
911 1590 if (!is_array($common)) {
@@ -949,7 +1628,8 @@
949 1628 delete_post_meta($post_id, '_metasync_robots_advanced');
950 1629 }
951 1630 } finally {
952 1631 unset($syncing_json[$post_id]);
1632 + unset($this->syncing_legacy_to_json[$post_id]);
953 1633 }
954 1634 }
955 1635 }