PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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
metasync / includes / class-metasync-plugin-sync.php

class-metasync-plugin-sync.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-plugin-sync.php

1,651 lines 62.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Post-Level SEO Plugin Sync
4 *
5 * Mirrors MetaSync post meta (`_metasync_*`) into the active third-party
6 * SEO plugins' post storage (Yoast, Rank Math, AIOSEO) so that posts and
7 * pages render MetaSync-managed values regardless of which plugin is
8 * actually rendering the frontend.
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 *
14 * @package MetaSync
15 * @subpackage MetaSync/includes
16 * @since 2.8.25
17 */
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 class Metasync_Plugin_Sync {
24
25 /**
26 * Singleton instance.
27 *
28 * @var self|null
29 */
30 private static $instance = null;
31
32 /**
33 * Guard: tracks post IDs currently being synced from JSON → legacy.
34 * Prevents sync_legacy_to_json from overwriting the correct JSON value.
35 *
36 * @var array
37 */
38 private $syncing_json_to_legacy = [];
39
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 /**
55 * MetaSync meta keys that trigger a sync when written.
56 *
57 * @var array
58 */
59 const WATCHED_KEYS = [
60 // Sidebar / persisted keys
61 '_metasync_seo_title',
62 '_metasync_seo_desc',
63 '_metasync_metatitle',
64 '_metasync_metadesc',
65 '_metasync_metakeywords',
66 '_metasync_robots_index',
67 '_metasync_robots_advanced',
68 '_metasync_og_title',
69 '_metasync_og_description',
70 '_metasync_og_image',
71 '_metasync_twitter_title',
72 '_metasync_twitter_description',
73 '_metasync_twitter_card',
74 '_metasync_canonical_url',
75 '_metasync_focus_keyword',
76 '_metasync_breadcrumb_title',
77 // OTTO volatile keys
78 '_metasync_otto_title',
79 '_metasync_otto_description',
80 '_metasync_otto_og_title',
81 '_metasync_otto_og_description',
82 '_metasync_otto_twitter_title',
83 '_metasync_otto_twitter_description',
84 '_metasync_otto_keywords',
85 '_metasync_otto_canonical',
86 ];
87
88 /**
89 * Internal OTTO meta keys mapped to the OTTO Persistence setting that
90 * governs whether their value may reach third-party SEO plugin storage.
91 *
92 * OTTO writes these keys on every sync regardless of any setting, because
93 * its own render filters and the admin reporting columns read them. Copying
94 * one into Yoast / Rank Math / AIOSEO is a different act: it is a permanent
95 * write into another plugin's storage that outlives MetaSync, which is
96 * exactly what the Persistence settings exist to control.
97 *
98 * Manually edited MetaSync fields are deliberately absent from this map.
99 * Saving those in the editor is an explicit user action, so they sync
100 * immediately and never consult these settings.
101 *
102 * @var array<string,string>
103 */
104 const OTTO_PERSISTENCE_KEYS = [
105 '_metasync_otto_title' => 'meta_title',
106 '_metasync_otto_description' => 'meta_description',
107 '_metasync_otto_keywords' => 'meta_keywords',
108 '_metasync_metakeywords' => 'meta_keywords',
109 '_metasync_otto_og_title' => 'og_title',
110 '_metasync_otto_og_description' => 'og_description',
111 '_metasync_otto_twitter_title' => 'twitter_title',
112 '_metasync_otto_twitter_description' => 'twitter_description',
113 ];
114
115 /**
116 * Get singleton instance.
117 *
118 * @return self
119 */
120 public static function get_instance() {
121 if (self::$instance === null) {
122 self::$instance = new self();
123 }
124 return self::$instance;
125 }
126
127 /**
128 * Whether a meta key's value may be mirrored into third-party SEO storage.
129 *
130 * Returns true for every key that is not an internal OTTO key, so manually
131 * edited MetaSync fields are unaffected. For the OTTO keys the matching
132 * Persistence setting decides.
133 *
134 * The class_exists() guard mirrors the call sites in
135 * otto/metasync-otto-seo-functions.php, including their fail-closed
136 * behaviour: a partially updated install can leave this file newer than the
137 * settings class, and with the class missing there is no setting that could
138 * authorise a permanent write into another plugin's storage.
139 *
140 * @param string $meta_key Meta key being written.
141 * @return bool True when the value may be synced.
142 */
143 private function otto_persistence_allows($meta_key) {
144 if (!isset(self::OTTO_PERSISTENCE_KEYS[$meta_key])) {
145 return true;
146 }
147
148 return class_exists('Metasync_Otto_Persistence_Settings')
149 && Metasync_Otto_Persistence_Settings::should_persist(
150 self::OTTO_PERSISTENCE_KEYS[$meta_key]
151 );
152 }
153
154 /**
155 * Sync MetaSync post meta to every active SEO plugin.
156 *
157 * When $fields is non-empty only those canonical keys are synced.
158 * When empty a full sync of all canonical keys is performed.
159 *
160 * @param int $post_id Post ID.
161 * @param array $fields Optional subset of canonical key/value pairs to sync.
162 * @return array Results keyed by plugin: ['yoast'=>bool,'rankmath'=>bool,'aioseo'=>bool].
163 */
164 public function sync_post($post_id, array $fields = []) {
165 static $syncing = [];
166
167 if (!empty($syncing[$post_id])) {
168 return [];
169 }
170 $syncing[$post_id] = true;
171
172 try {
173 $results = [];
174
175 if ($post_id <= 0) {
176 return $results;
177 }
178
179 $data = $this->collect_post_data($post_id, $fields);
180
181 if (empty($data)) {
182 return $results;
183 }
184
185 if ($this->is_yoast_active()) {
186 $results['yoast'] = $this->sync_yoast((int) $post_id, $data);
187 }
188
189 if ($this->is_rankmath_active()) {
190 $results['rankmath'] = $this->sync_rankmath((int) $post_id, $data);
191 }
192
193 if ($this->is_aioseo_active()) {
194 $results['aioseo'] = $this->sync_aioseo((int) $post_id, $data);
195 }
196
197 // Write sync timestamp
198 $ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
199 $sync_data = !empty($ts) ? json_decode($ts, true) : [];
200 if (!is_array($sync_data)) {
201 $sync_data = [];
202 }
203 if ($results['yoast'] ?? false) {
204 $sync_data['yoast'] = gmdate('c');
205 }
206 if ($results['rankmath'] ?? false) {
207 $sync_data['rankmath'] = gmdate('c');
208 }
209 if ($results['aioseo'] ?? false) {
210 $sync_data['aioseo'] = gmdate('c');
211 }
212 if (!empty($sync_data)) {
213 update_post_meta($post_id, '_metasync_plugin_sync_ts', wp_json_encode($sync_data));
214 }
215
216 return $results;
217 } finally {
218 unset($syncing[$post_id]);
219 }
220 }
221
222 /**
223 * Hook handler for updated_post_meta / added_post_meta.
224 *
225 * Fires sync_post when a watched MetaSync meta key is written.
226 *
227 * @param int $meta_id Meta row ID (unused).
228 * @param int $post_id Post ID.
229 * @param string $meta_key Meta key being written.
230 * @param mixed $meta_value Meta value being written.
231 */
232 public function on_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
233 $watched = [
234 // Sidebar / persisted keys
235 '_metasync_seo_title' => 'title',
236 '_metasync_seo_desc' => 'desc',
237 '_metasync_metatitle' => 'title',
238 '_metasync_metadesc' => 'desc',
239 '_metasync_robots_index' => 'noindex',
240 '_metasync_og_title' => 'og_title',
241 '_metasync_og_description' => 'og_desc',
242 '_metasync_og_image' => 'og_image',
243 '_metasync_twitter_title' => 'twitter_title',
244 '_metasync_twitter_description' => 'twitter_desc',
245 '_metasync_twitter_card' => 'twitter_card',
246 '_metasync_canonical_url' => 'canonical',
247 '_metasync_focus_keyword' => 'focus_keyword',
248 '_metasync_breadcrumb_title' => 'breadcrumb_title',
249 '_metasync_robots_advanced' => '_robots_advanced_json',
250 // Internal OTTO keys. OTTO stores these on every sync regardless of
251 // any setting; whether they may reach third-party storage is decided
252 // by OTTO_PERSISTENCE_KEYS above.
253 '_metasync_otto_title' => 'title',
254 '_metasync_otto_description' => 'desc',
255 '_metasync_otto_og_title' => 'og_title',
256 '_metasync_otto_og_description' => 'og_desc',
257 '_metasync_otto_twitter_title' => 'twitter_title',
258 '_metasync_otto_twitter_description' => 'twitter_desc',
259 '_metasync_otto_keywords' => 'focus_keyword',
260 '_metasync_metakeywords' => 'focus_keyword',
261 // Legacy meta box keys → rebuild JSON
262 'metasync_common_robots' => '_legacy_robots_to_json',
263 'metasync_advance_robots' => '_legacy_robots_to_json',
264 ];
265
266 if (!isset($watched[$meta_key])) {
267 return;
268 }
269
270 // An internal OTTO field reaches third-party SEO storage only while its
271 // OTTO Persistence setting is enabled. With the setting off the value
272 // stays stored under its own key -- OTTO keeps rendering and reporting
273 // on it -- but Yoast / Rank Math / AIOSEO are left untouched. Manually
274 // edited MetaSync fields are not in the map and so are never gated.
275 if (!$this->otto_persistence_allows($meta_key)) {
276 return;
277 }
278
279 $canonical_key = $watched[$meta_key];
280
281 // JSON key -- do a full sync + mirror to legacy meta boxes
282 if ($canonical_key === '_robots_advanced_json') {
283 $this->sync_post((int) $post_id);
284 $this->sync_to_legacy_meta((int) $post_id, $meta_value);
285 // Re-sync at shutdown only when Yoast is active — Yoast's indexable
286 // watcher can overwrite our values during the same request.
287 if ($this->is_yoast_active()) {
288 $sync_instance = $this;
289 $sync_post_id = (int) $post_id;
290 add_action('shutdown', function() use ($sync_instance, $sync_post_id) {
291 // By shutdown this request has typically seeded the OG defaults
292 // memo for the post (the sync above reads it); the row write
293 // has since landed, so drop the memo or the re-sync below
294 // compares against the pre-save title/excerpt.
295 // @phpstan-ignore-next-line function.alreadyNarrowedType
296 if (method_exists('Metasync_OpenGraph', 'clear_default_og_values_memo')) {
297 Metasync_OpenGraph::clear_default_og_values_memo();
298 }
299 $sync_instance->sync_post($sync_post_id);
300 }, 0);
301 }
302 return;
303 }
304
305 // Legacy meta box → rebuild _metasync_robots_advanced JSON
306 if ($canonical_key === '_legacy_robots_to_json') {
307 $this->sync_legacy_to_json((int) $post_id);
308 return;
309 }
310
311 // For noindex: convert 'noindex' string to bool
312 if ($canonical_key === 'noindex') {
313 $value = ($meta_value === 'noindex');
314 } else {
315 $value = $meta_value;
316 }
317
318 $this->sync_post(
319 (int) $post_id,
320 [$canonical_key => $value]
321 );
322 }
323
324 /**
325 * Hook handler for deleted_post_meta.
326 *
327 * Only the legacy meta box keys are handled here. Unticking the last
328 * checkbox in the Common Robots meta box deletes metasync_common_robots
329 * instead of updating it, so without a delete hook the mirrored
330 * _metasync_robots_advanced JSON kept the stale directive — and because the
331 * JSON is the highest-priority source in the output resolver, the page went
332 * on emitting a directive the editor had just cleared.
333 *
334 * Deletes of the JSON key itself are deliberately NOT routed into
335 * on_meta_updated: that path mirrors the value back onto the legacy meta
336 * boxes, so an empty value would wipe them.
337 *
338 * @param array $meta_ids Meta row IDs (unused).
339 * @param int $post_id Post ID.
340 * @param string $meta_key Meta key being deleted.
341 */
342 public function on_meta_deleted($meta_ids, $post_id, $meta_key) {
343 if ($meta_key !== 'metasync_common_robots' && $meta_key !== 'metasync_advance_robots') {
344 return;
345 }
346
347 $this->sync_legacy_to_json((int) $post_id);
348 }
349
350 // ------------------------------------------------------------------
351 // Data collection
352 // ------------------------------------------------------------------
353
354 /**
355 * Collect canonical SEO data from all MetaSync post meta.
356 *
357 * Reads all meta in one get_post_custom() call for performance.
358 * When $fields is non-empty, the result is filtered to only those keys.
359 *
360 * @param int $post_id Post ID.
361 * @param array $fields Optional pre-resolved canonical key/value pairs.
362 * @return array Canonical data array.
363 */
364 private function collect_post_data($post_id, array $fields = []) {
365 // If caller already resolved specific fields, return them directly.
366 // Canonical still gets validated — this branch serves the
367 // updated_post_meta fast-path, which would otherwise mirror a raw
368 // (possibly corrupted) value into third-party storage.
369 if (!empty($fields)) {
370 if (array_key_exists('canonical', $fields)) {
371 $fields['canonical'] = Metasync_Canonical_Sanitizer::sanitize($fields['canonical']);
372 if ($fields['canonical'] === '') {
373 unset($fields['canonical']);
374 }
375 }
376
377 // Same reasoning for the social title/description fields: on this
378 // fast-path the value arrives straight from the meta write, so a stored
379 // "Auto Draft" pre-fill placeholder would be mirrored verbatim into
380 // Yoast/RankMath/AIOSEO and emitted there as og:title. Drop it instead so
381 // each plugin keeps whatever it already has.
382 // @phpstan-ignore-next-line function.alreadyNarrowedType
383 if (method_exists('Metasync_OpenGraph', 'is_auto_draft_title')) {
384 foreach (['og_title', 'og_desc', 'twitter_title', 'twitter_desc'] as $social_field) {
385 if (array_key_exists($social_field, $fields)
386 && Metasync_OpenGraph::is_auto_draft_title($fields[$social_field])
387 ) {
388 unset($fields[$social_field]);
389 }
390 }
391 }
392
393 return $this->apply_feature_flags_to_payload($fields);
394 }
395
396 $all_meta = get_post_custom($post_id);
397
398 $get = function ($key) use ($all_meta, $post_id) {
399 if (!isset($all_meta[$key])) {
400 return '';
401 }
402 $value = is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
403
404 // The meta box pre-fills the social title/description fields from the post
405 // title, which is the "Auto Draft" placeholder on a brand-new post. Collapse
406 // it to '' here so the placeholder is never mirrored into Yoast/RankMath/
407 // AIOSEO storage, where those plugins would emit it as og:title.
408 // Same sanitize-at-the-source placement as Metasync_Canonical_Sanitizer below.
409 //
410 // method_exists (not just class_exists) for the same reason
411 // sync_layer_handles() checks: this runs on meta writes during front-end
412 // requests, and a partially updated install can leave an older
413 // class-metasync-opengraph.php beside this file.
414 // @phpstan-ignore-next-line function.alreadyNarrowedType
415 if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')
416 && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS')
417 && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
418 ) {
419 $value = Metasync_OpenGraph::strip_auto_draft_title($value);
420 }
421
422 // A social title that is a verbatim snapshot of the post title is the
423 // old pre-fill, not a customization — mirroring it would hand a
424 // third-party plugin a copy that a rename leaves stale (and that
425 // outranks OTTO's fresh value there). Collapsed for the same reason
426 // as the placeholder above; the chain in $first then falls through
427 // to OTTO's staging key.
428 // @phpstan-ignore-next-line function.alreadyNarrowedType
429 if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot')
430 && defined('Metasync_OpenGraph::TITLE_DEFAULTED_KEYS')
431 && in_array($key, Metasync_OpenGraph::TITLE_DEFAULTED_KEYS, true)
432 ) {
433 $value = Metasync_OpenGraph::strip_title_snapshot($post_id, $key, $value);
434 }
435
436 // A social description that is a verbatim snapshot of the resolved
437 // excerpt is the old pre-fill, not a customization — mirroring it
438 // would hand a third-party plugin a copy that a later excerpt or
439 // content edit leaves stale. Collapsed for the same reason as the
440 // title snapshot above.
441 // @phpstan-ignore-next-line function.alreadyNarrowedType
442 if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot')
443 && defined('Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS')
444 && in_array($key, Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS, true)
445 ) {
446 $value = Metasync_OpenGraph::strip_description_snapshot($post_id, $key, $value);
447 }
448
449 return $value;
450 };
451
452 $data = [];
453
454 // Helper: first non-empty value from a list of meta keys.
455 //
456 // Internal OTTO keys are skipped while their OTTO Persistence setting is
457 // disabled. Gating here and not only in on_meta_updated() is what makes
458 // the setting hold for the callers that re-read meta themselves rather
459 // than passing a resolved field: the sync at the end of OTTO SSR
460 // processing (metasync_update_comprehensive_seo_fields), the MCP OTTO
461 // refresh tool, and the robots full-sync branch below.
462 //
463 // Each chain lists the manually edited key first and the internal OTTO
464 // key last, so a customer-entered value is unaffected and still wins.
465 $first = function (...$keys) use ($get) {
466 foreach ($keys as $key) {
467 if (!$this->otto_persistence_allows($key)) {
468 continue;
469 }
470 $val = $get($key);
471 if (!empty($val)) {
472 return $val;
473 }
474 }
475 return '';
476 };
477
478 // Order comes from Metasync_Seo_Precedence, the one place it is defined.
479 //
480 // Imported values come last so a sync triggered by something else still
481 // reports a title rather than an empty one. They are deliberately NOT in
482 // WATCHED_KEYS or the on_meta_updated map above: a sync mirrors the
483 // canonical value into an active third-party plugin's own storage, and
484 // that plugin renders at a higher effective precedence than OTTO — so
485 // syncing on an imported write would put the imported title back in
486 // front of OTTO, which is the exact behaviour this key exists to avoid.
487 $data['title'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_TITLE));
488
489 $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION));
490
491 // Robots directives: check _metasync_robots_advanced JSON first,
492 // then fall back to metasync_common_robots array + metasync_advance_robots array
493 $robots_json_raw = $get('_metasync_robots_advanced');
494 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
495
496 if (is_array($robots_json)) {
497 $data['noindex'] = $this->resolve_synced_noindex(
498 $robots_json,
499 $get('_metasync_robots_index'),
500 $get('metasync_common_robots')
501 );
502 $data['nofollow'] = !empty($robots_json['nofollow']);
503 $data['noarchive'] = !empty($robots_json['noarchive']);
504 $data['nosnippet'] = !empty($robots_json['nosnippet']);
505 $data['noimageindex'] = !empty($robots_json['noimageindex']);
506 $data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null);
507 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
508 $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);
509 } else {
510 // noindex: dedicated key, falling back to the Common Robots checkbox
511 // array — same sources the front-end emitter honours.
512 $data['noindex'] = $this->resolve_synced_noindex(
513 null,
514 $get('_metasync_robots_index'),
515 $get('metasync_common_robots')
516 );
517
518 // Common robots array (serialized)
519 $common_raw = $get('metasync_common_robots');
520 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
521 if (!is_array($common_robots)) {
522 $common_robots = [];
523 }
524
525 $data['nofollow'] = !empty($common_robots['nofollow']);
526 $data['noarchive'] = !empty($common_robots['noarchive']);
527 $data['nosnippet'] = !empty($common_robots['nosnippet']);
528 $data['noimageindex'] = !empty($common_robots['noimageindex']);
529
530 // Advance robots array (serialized)
531 $adv_raw = $get('metasync_advance_robots');
532 $adv_robots = !empty($adv_raw) ? maybe_unserialize($adv_raw) : [];
533 if (!is_array($adv_robots)) {
534 $adv_robots = [];
535 }
536
537 // Each advance-robots directive is stored as ['enable' => .., 'length' => ..].
538 // Read the length scalar (honouring the enable flag) instead of casting the
539 // whole sub-array — (int) of a non-empty array is 1, which is what produced the
540 // "max-snippet:1" instead of "-1" and dropped the image-preview value. Mirrors
541 // sync_legacy_to_json() so both directions read the legacy format identically.
542 $data['max_snippet'] = !empty($adv_robots['max-snippet']['enable'])
543 ? (isset($adv_robots['max-snippet']['length']) ? (int) $adv_robots['max-snippet']['length'] : -1)
544 : null;
545 $data['max_image_preview'] = !empty($adv_robots['max-image-preview']['enable'])
546 ? (isset($adv_robots['max-image-preview']['length']) ? (string) $adv_robots['max-image-preview']['length'] : 'large')
547 : null;
548 $data['max_video_preview'] = !empty($adv_robots['max-video-preview']['enable'])
549 ? (isset($adv_robots['max-video-preview']['length']) ? (int) $adv_robots['max-video-preview']['length'] : -1)
550 : null;
551 }
552
553 // Social / OG: persisted > volatile OTTO
554 $data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title');
555 $data['og_desc'] = $first('_metasync_og_description', '_metasync_otto_og_description');
556 $data['og_image'] = $get('_metasync_og_image');
557
558 // Twitter: persisted > volatile OTTO
559 $data['twitter_title'] = $first('_metasync_twitter_title', '_metasync_otto_twitter_title');
560 $data['twitter_desc'] = $first('_metasync_twitter_description', '_metasync_otto_twitter_description');
561 $data['twitter_card'] = $get('_metasync_twitter_card');
562
563 // Canonical, focus keyword, breadcrumb
564 // Canonical is validated at the source so a corrupted value ("Array")
565 // never propagates into Yoast/RankMath/AIOSEO storage.
566 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
567 // The keyword takes its order from the resolver — customer value, then
568 // OTTO's volatile tier, then OTTO's persisted tier — so the persistence
569 // gating in $first() reaches the persisted copy too. Previously the
570 // persisted copy sat on the customer key, outranking the live one.
571 $data['focus_keyword'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD));
572 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
573
574 return $this->apply_feature_flags_to_payload($data);
575 }
576
577 /**
578 * Drop payload keys whose owning feature is switched off in Editor Settings.
579 *
580 * A disabled meta box means MetaSync must not interfere with that feature
581 * anywhere: it emits nothing, suppresses nothing, and — the part this
582 * guard covers — does not mirror its values into Yoast / Rank Math /
583 * AIOSEO storage. Without the drop, a sync fired while the feature is off
584 * resolves the MetaSync value with the feature already stripped and then
585 * writes that result over the third-party plugin's own value (Rank Math's
586 * stored noindex replaced by a plain 'index'). The writers are all
587 * array_key_exists-guarded, so removing a key here skips every write of it.
588 *
589 * @param array $data Canonical payload keyed by canonical field name.
590 * @return array
591 */
592 private function apply_feature_flags_to_payload(array $data) {
593 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) {
594 unset(
595 $data['noindex'],
596 $data['nofollow'],
597 $data['noarchive'],
598 $data['nosnippet'],
599 $data['noimageindex']
600 );
601 }
602 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::ADVANCE_ROBOTS)) {
603 unset(
604 $data['max_snippet'],
605 $data['max_image_preview'],
606 $data['max_video_preview']
607 );
608 }
609 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
610 unset(
611 $data['og_title'],
612 $data['og_desc'],
613 $data['og_image'],
614 $data['twitter_title'],
615 $data['twitter_desc'],
616 $data['twitter_card']
617 );
618 }
619 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
620 unset($data['canonical']);
621 }
622
623 return $data;
624 }
625
626 /**
627 * Resolve the noindex directive for syncing the way the front-end emitter
628 * does: any of the three sources saying noindex wins, and none can veto
629 * another. The JSON rebuilt by sync_legacy_to_json() deliberately omits
630 * noindex, so reading the JSON alone reported a checkbox-only noindex as
631 * false — and the writers translated that into an explicit 'index' that
632 * overwrote a noindex the third-party plugin already held.
633 *
634 * @param mixed $robots_json Decoded _metasync_robots_advanced payload (or null).
635 * @param mixed $robots_index Raw _metasync_robots_index meta value.
636 * @param mixed $common_raw Raw metasync_common_robots meta value.
637 * @return bool
638 */
639 private function resolve_synced_noindex($robots_json, $robots_index, $common_raw) {
640 if (is_array($robots_json) && !empty($robots_json['noindex'])) {
641 return true;
642 }
643 if ($robots_index === 'noindex') {
644 return true;
645 }
646 if (!empty($common_raw)) {
647 $common = maybe_unserialize($common_raw);
648 if (is_array($common) && !empty($common['noindex'])) {
649 return true;
650 }
651 }
652
653 return false;
654 }
655
656 // ------------------------------------------------------------------
657 // Plugin detectors
658 // ------------------------------------------------------------------
659
660 /**
661 * Check whether Yoast SEO (free or premium) is active.
662 *
663 * @return bool
664 */
665 private function is_yoast_active() {
666 $this->ensure_plugin_api();
667 return is_plugin_active('wordpress-seo/wp-seo.php')
668 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
669 }
670
671 /**
672 * Check whether Rank Math SEO is active.
673 *
674 * @return bool
675 */
676 private function is_rankmath_active() {
677 $this->ensure_plugin_api();
678 return is_plugin_active('seo-by-rank-math/rank-math.php')
679 || is_plugin_active('seo-by-rankmath/rank-math.php');
680 }
681
682 /**
683 * Check whether AIOSEO (free or pro) is active.
684 *
685 * @return bool
686 */
687 private function is_aioseo_active() {
688 $this->ensure_plugin_api();
689 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
690 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
691 }
692
693 /**
694 * Ensure is_plugin_active() is loaded on the frontend.
695 */
696 private function ensure_plugin_api() {
697 if (!function_exists('is_plugin_active')) {
698 require_once ABSPATH . 'wp-admin/includes/plugin.php';
699 }
700 }
701
702 // ------------------------------------------------------------------
703 // Per-plugin sync
704 // ------------------------------------------------------------------
705
706 /**
707 * Whether third-party SEO storage may be written at all.
708 *
709 * The site owner's consent switch. Checked once per plugin dispatch rather
710 * than per field so a sync that is not permitted does no work and, more
711 * importantly, leaves no half-written row behind.
712 *
713 * @return bool
714 */
715 private function third_party_writes_allowed() {
716 return class_exists('Metasync_Seo_Backup') && Metasync_Seo_Backup::is_enabled();
717 }
718
719 /**
720 * Write one third-party post-meta field, preserving what it held before.
721 *
722 * This bridge runs on `updated_post_meta`, which means it fires from inside
723 * an OTTO sync's `_metasync_otto_*` write — before the same sync reaches its
724 * own direct Rank Math / Yoast writes further down. It is therefore usually
725 * the first code to touch the customer's value, and the backup it takes here
726 * is the one that holds the true original.
727 *
728 * @param int $post_id Post ID.
729 * @param string $key Third-party meta key.
730 * @param mixed $value Value to write.
731 * @return bool True when the write happened.
732 */
733 private function write_post_field($post_id, $key, $value) {
734 return class_exists('Metasync_Seo_Backup')
735 && Metasync_Seo_Backup::write_post_meta($post_id, $key, $value);
736 }
737
738 /**
739 * Mirror canonical data into Yoast post meta and indexable cache.
740 *
741 * @param int $post_id Post ID.
742 * @param array $data Canonical key/value pairs.
743 * @return bool True when at least one value-bearing field was mirrored,
744 * false when the payload carried nothing to write. The caller
745 * stamps `_metasync_plugin_sync_ts` from this, so it must
746 * describe what was written and not merely that dispatch was
747 * reached.
748 *
749 * Robots directives are excluded from that answer on purpose.
750 * collect_post_data() synthesises noindex/nofollow on every
751 * payload — absent any stored value they resolve to plain
752 * index/follow — so they are written on every sync and say
753 * nothing about whether a canonical value existed to mirror.
754 * Counting them would make the result unconditionally true,
755 * which is the bug this return contract exists to fix.
756 */
757 private function sync_yoast($post_id, array $data) {
758 if (!$this->third_party_writes_allowed()) {
759 return false;
760 }
761
762 $wrote = false;
763
764 // title
765 if (!empty($data['title'])) {
766 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_title', (string) $data['title']);
767 }
768
769 // description -- strip newlines first
770 if (!empty($data['desc'])) {
771 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
772 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
773 }
774
775 // noindex: '0'=default, '1'=noindex, '2'=index
776 if (array_key_exists('noindex', $data)) {
777 $val = $data['noindex'] ? '1' : '2';
778 $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
779 }
780
781 // nofollow: '0'=follow, '1'=nofollow
782 if (array_key_exists('nofollow', $data)) {
783 $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
784 }
785
786 // advanced robots: comma-separated NO spaces
787 $adv = [];
788 if (!empty($data['noarchive'])) {
789 $adv[] = 'noarchive';
790 }
791 if (!empty($data['nosnippet'])) {
792 $adv[] = 'nosnippet';
793 }
794 if (!empty($data['noimageindex'])) {
795 $adv[] = 'noimageindex';
796 }
797 // Written unconditionally so clearing the last directive clears the
798 // field. Like the other robots writes it carries no canonical value,
799 // so it does not make this a successful sync.
800 $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
801
802 // OG
803 if (!empty($data['og_title'])) {
804 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']) || $wrote;
805 }
806 if (!empty($data['og_desc'])) {
807 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']) || $wrote;
808 }
809 if (!empty($data['og_image'])) {
810 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])) || $wrote;
811 }
812
813 // Twitter
814 if (!empty($data['twitter_title'])) {
815 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']) || $wrote;
816 }
817 if (!empty($data['twitter_desc'])) {
818 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']) || $wrote;
819 }
820
821 // Canonical, focus keyword, breadcrumb
822 if (!empty($data['canonical'])) {
823 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])) || $wrote;
824 }
825 if (!empty($data['focus_keyword'])) {
826 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']) || $wrote;
827 }
828 if (!empty($data['breadcrumb_title'])) {
829 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']) || $wrote;
830 }
831
832 // Update wp_yoast_indexable cache row for immediate effect
833 global $wpdb;
834 $indexable_table = $wpdb->prefix . 'yoast_indexable';
835
836 $updates = [];
837 if (!empty($data['title'])) {
838 $updates['title'] = mb_substr($data['title'], 0, 191);
839 }
840 if (!empty($data['desc'])) {
841 $updates['description'] = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
842 }
843 if (array_key_exists('noindex', $data)) {
844 $updates['is_robots_noindex'] = $data['noindex'] ? 1 : 0;
845 }
846 if (array_key_exists('nofollow', $data)) {
847 $updates['is_robots_nofollow'] = $data['nofollow'] ? 1 : 0;
848 }
849 if (array_key_exists('noarchive', $data)) {
850 $updates['is_robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
851 }
852 if (array_key_exists('nosnippet', $data)) {
853 $updates['is_robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
854 }
855 if (array_key_exists('noimageindex', $data)) {
856 $updates['is_robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
857 }
858 if (!empty($data['og_title'])) {
859 $updates['open_graph_title'] = mb_substr($data['og_title'], 0, 191);
860 }
861 if (!empty($data['og_image'])) {
862 $updates['open_graph_image'] = $data['og_image'];
863 }
864 if (!empty($data['twitter_title'])) {
865 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
866 }
867 if (!empty($data['twitter_card'])) {
868 // twitter_card column only exists in newer Yoast versions; skip if absent
869 $col_check = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
870 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
871 DB_NAME,
872 $indexable_table
873 ));
874 if ($col_check) {
875 $updates['twitter_card'] = $data['twitter_card'];
876 }
877 }
878 if (!empty($data['canonical'])) {
879 $updates['canonical'] = $data['canonical'];
880 }
881 if (!empty($data['focus_keyword'])) {
882 $updates['primary_focus_keyword'] = mb_substr($data['focus_keyword'], 0, 191);
883 }
884 if (!empty($data['breadcrumb_title'])) {
885 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
886 }
887
888 if (!empty($updates)) {
889 $row_exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
890 "SELECT id FROM {$wpdb->prefix}yoast_indexable WHERE object_id = %d AND object_type = 'post'",
891 $post_id
892 ));
893
894 // This probe decides two things at once: the value of the write-once
895 // row_existed marker, and whether the write below is an UPDATE or an
896 // INSERT. A failed probe answers "no row" for a row that is really
897 // there, which commits a '0' marker telling a restore to delete the
898 // customer's row and fires an INSERT against a row that already
899 // exists. Neither is correctable afterwards, so an unreadable probe
900 // has to abandon the indexable write entirely.
901 //
902 // The post meta above has already landed, so the receipt still
903 // reports it. Only the indexable is skipped.
904 if (!Metasync_Seo_Backup::db_read_succeeded()) {
905 return $wrote;
906 }
907
908 // No original saved means no write, exactly as on the AIOSEO table.
909 // This row is what Yoast actually renders from, so a restore that
910 // cannot reach it puts the meta back and leaves the customer's live
911 // pages still showing OTTO's values.
912 $backups_created = [];
913 if (!$this->backup_yoast_indexable_columns($post_id, $indexable_table, $updates, (bool) $row_exists, $backups_created)) {
914 return $wrote;
915 }
916
917 if ($row_exists) {
918 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
919 $indexable_table,
920 $updates,
921 ['object_id' => $post_id, 'object_type' => 'post']
922 );
923 } else {
924 $post = get_post($post_id);
925 $insert = array_merge([
926 'object_id' => $post_id,
927 'object_type' => 'post',
928 'object_sub_type' => $post ? $post->post_type : 'post',
929 'post_status' => $post ? $post->post_status : 'publish',
930 'author_id' => $post ? (int) $post->post_author : 0,
931 'is_robots_noindex' => 0,
932 'is_robots_nofollow' => 0,
933 'is_robots_noarchive' => 0,
934 'is_robots_nosnippet' => 0,
935 'is_robots_noimageindex' => 0,
936 'is_cornerstone' => 0,
937 'created_at' => current_time('mysql'),
938 'updated_at' => current_time('mysql'),
939 ], $updates);
940
941 // The row_existed='0' marker was recorded before this insert,
942 // because a marker that will not save has to be able to veto the
943 // write. A failed insert means no row of ours exists, and a
944 // marker left saying otherwise would let a restore delete a row
945 // Yoast or the customer creates afterwards. The per-column
946 // backups recorded beside it go too: they are write-once, so a
947 // column captured as NULL because there was no row would stay
948 // NULL for good and blank a real value the customer later puts
949 // in that row.
950 //
951 // The post meta above has already been written, so the return
952 // still reports what landed. Claiming nothing was written would
953 // tell the conflict handler this post is unsynced to Yoast while
954 // Yoast's own meta holds our values, and it would hand tag
955 // ownership to the wrong plugin.
956 if ($wpdb->insert($indexable_table, $insert) === false) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
957 Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
958 return $wrote;
959 }
960 }
961 }
962
963 return $wrote;
964 }
965
966 /**
967 * Preserve the wp_yoast_indexable columns this sync is about to overwrite.
968 *
969 * Yoast serves the frontend from this table, not from post meta, so the
970 * meta backups taken by write_post_field() do not by themselves make the
971 * change reversible. Same shape as backup_aioseo_columns():
972 *
973 * - one backup per column being written, so a restore can put the originals
974 * back and leave every other column of the user's row alone;
975 * - whether the row existed at all, so a restore can delete a row that only
976 * exists because we created it instead of leaving an empty shell behind.
977 *
978 * @param int $post_id Post ID.
979 * @param string $table Fully prefixed indexable table name.
980 * @param array $updates Columns and values about to be written.
981 * @param bool $row_existed Whether Yoast already had a row for this post.
982 * @param array $created Out-param, filled with the backup fields this
983 * call created, so a failed write can withdraw
984 * exactly its own rows and no one else's.
985 * @return bool True when every original was preserved and the caller may write.
986 */
987 private function backup_yoast_indexable_columns($post_id, $table, array $updates, $row_existed, array &$created) {
988 $created = [];
989
990 if (!class_exists('Metasync_Seo_Backup')) {
991 return false;
992 }
993
994 global $wpdb;
995
996 if (!Metasync_Seo_Backup::record_marker(
997 'post',
998 $post_id,
999 'yoast_indexable_row_existed',
1000 $row_existed ? '1' : '0',
1001 $marker_created
1002 )) {
1003 return false;
1004 }
1005
1006 if ($marker_created) {
1007 $created[] = 'yoast_indexable_row_existed';
1008 }
1009
1010 $columns = array_keys($updates);
1011 if (empty($columns)) {
1012 return true;
1013 }
1014
1015 $current = null;
1016 if ($row_existed) {
1017 $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1018 // Column list is esc_sql()'d above; the table is a fixed literal.
1019 $current = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1020 $wpdb->prepare(
1021 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal
1022 "SELECT {$select} FROM {$wpdb->prefix}yoast_indexable WHERE object_id = %d AND object_type = 'post'",
1023 $post_id
1024 ),
1025 ARRAY_A
1026 );
1027
1028 // The row was there a moment ago, so a null answer now is a failed
1029 // read, not an empty row. Recording it as "every column was NULL"
1030 // would tell a later restore to delete values it should put back,
1031 // which is the exact loss this layer exists to prevent. No write is
1032 // happening, so the marker recorded above has to go too — a marker
1033 // left describing a write that never ran is a stale story.
1034 if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1035 Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1036 return false;
1037 }
1038 }
1039
1040 foreach ($columns as $column) {
1041 // A missing row and a NULL column are the same thing to a restore:
1042 // there was no value here, so put nothing back.
1043 $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1044
1045 $field = 'yoast_indexable_' . $column;
1046
1047 // One unsaved column is enough to refuse the whole write: a
1048 // half-original, half-OTTO row is something no restore can unpick.
1049 // The refusal also means the caller writes nothing, so withdraw the
1050 // marker and the columns recorded so far — the same rule as the
1051 // failed-insert path in sync_yoast(). Left behind, a row_existed='0'
1052 // marker would let a restore delete a row the customer creates
1053 // later, and a NULL column backup would blank a real value in it.
1054 if (!Metasync_Seo_Backup::backup_before_overwrite(
1055 'post',
1056 $post_id,
1057 $field,
1058 $updates[$column],
1059 $current_value,
1060 $column_created
1061 )) {
1062 Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1063 return false;
1064 }
1065
1066 if ($column_created) {
1067 $created[] = $field;
1068 }
1069 }
1070
1071 return true;
1072 }
1073
1074 /**
1075 * Mirror canonical data into Rank Math post meta.
1076 *
1077 * @param int $post_id Post ID.
1078 * @param array $data Canonical key/value pairs.
1079 * @return bool True when at least one value-bearing field was mirrored,
1080 * false when the payload carried nothing to write. Robots
1081 * directives are excluded for the reason given on sync_yoast().
1082 */
1083 private function sync_rankmath($post_id, array $data) {
1084 if (!$this->third_party_writes_allowed()) {
1085 return false;
1086 }
1087
1088 $wrote = false;
1089
1090 // title, desc
1091 if (!empty($data['title'])) {
1092 $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
1093 }
1094 if (!empty($data['desc'])) {
1095 $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
1096 }
1097
1098 // robots: PHP indexed array
1099 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
1100 $existing = get_post_meta($post_id, 'rank_math_robots', true);
1101 $robots = is_array($existing) ? $existing : [];
1102 $robots = array_values(array_diff($robots, ['index', 'noindex', 'follow', 'nofollow']));
1103 if (array_key_exists('noindex', $data)) {
1104 $robots[] = $data['noindex'] ? 'noindex' : 'index';
1105 }
1106 if (array_key_exists('nofollow', $data)) {
1107 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
1108 }
1109 $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1110 }
1111
1112 // Advanced robots: max-* go into rank_math_advanced_robots
1113 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
1114 $has_adv = false;
1115 foreach ($adv_keys as $k) {
1116 if (array_key_exists($k, $data)) {
1117 $has_adv = true;
1118 break;
1119 }
1120 }
1121 if ($has_adv) {
1122 $existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true);
1123 $adv = is_array($existing_adv) ? $existing_adv : [];
1124 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
1125 $val = (int) $data['max_snippet'];
1126 $adv['max-snippet'] = (string) $val;
1127 }
1128 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
1129 $allowed = ['none', 'standard', 'large'];
1130 if (in_array($data['max_image_preview'], $allowed, true)) {
1131 $adv['max-image-preview'] = (string) $data['max_image_preview'];
1132 }
1133 }
1134 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
1135 $val = (int) $data['max_video_preview'];
1136 $adv['max-video-preview'] = (string) $val;
1137 }
1138 if (!empty($adv)) {
1139 $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
1140 }
1141 }
1142
1143 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
1144 if (array_key_exists('noarchive', $data) || array_key_exists('nosnippet', $data) || array_key_exists('noimageindex', $data)) {
1145 $existing = get_post_meta($post_id, 'rank_math_robots', true);
1146 $robots = is_array($existing) ? $existing : [];
1147 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1148 if (!array_key_exists($dir, $data)) {
1149 continue;
1150 }
1151 $robots = array_values(array_diff($robots, [$dir]));
1152 if (!empty($data[$dir])) {
1153 $robots[] = $dir;
1154 }
1155 }
1156 $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1157 }
1158
1159 // OG
1160 if (!empty($data['og_title'])) {
1161 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
1162 }
1163 if (!empty($data['og_desc'])) {
1164 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
1165 }
1166 if (!empty($data['og_image'])) {
1167 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])) || $wrote;
1168 $img_id = attachment_url_to_postid($data['og_image']);
1169 if ($img_id) {
1170 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image_id', $img_id) || $wrote;
1171 }
1172 }
1173
1174 // Twitter
1175 if (!empty($data['twitter_title'])) {
1176 $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
1177 }
1178 if (!empty($data['twitter_desc'])) {
1179 $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
1180 }
1181 if (!empty($data['twitter_card'])) {
1182 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
1183 if (in_array($data['twitter_card'], $valid_cards, true)) {
1184 $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
1185 }
1186 }
1187
1188 // Canonical, focus keyword, breadcrumb
1189 if (!empty($data['canonical'])) {
1190 $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
1191 }
1192 if (!empty($data['focus_keyword'])) {
1193 $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
1194 }
1195 if (!empty($data['breadcrumb_title'])) {
1196 $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
1197 }
1198
1199 return $wrote;
1200 }
1201
1202 /**
1203 * Preserve the AIOSEO columns a sync is about to overwrite.
1204 *
1205 * AIOSEO keeps post SEO data in its own `aioseo_posts` table rather than in
1206 * post meta, so there is no meta row to save and no field a restore could
1207 * delete. Two things are recorded instead, both as post meta on the post
1208 * itself:
1209 *
1210 * - one backup per column being written, so a restore can put the original
1211 * values back and leave every other column of the user's row alone;
1212 * - whether the row existed at all, so a restore can delete a row that only
1213 * exists because we created it instead of leaving an empty shell behind.
1214 *
1215 * @param int $post_id Post ID.
1216 * @param string $table Fully prefixed AIOSEO table name.
1217 * @param array $row Columns and values about to be written.
1218 * @param bool $row_existed Whether AIOSEO already had a row for this post.
1219 * @param array $created Out-param, filled with the backup fields this
1220 * call created, so a failed write can withdraw
1221 * exactly its own rows and no one else's.
1222 * @return bool True when every original was preserved and the caller may write.
1223 */
1224 private function backup_aioseo_columns($post_id, $table, array $row, $row_existed, array &$created) {
1225 $created = [];
1226
1227 if (!class_exists('Metasync_Seo_Backup')) {
1228 return false;
1229 }
1230
1231 global $wpdb;
1232
1233 // Whether the row pre-existed is what a restore uses to choose between
1234 // putting the original columns back and deleting a row that only exists
1235 // because we made it. A marker that will not record is as disqualifying
1236 // as a column that will not.
1237 if (!Metasync_Seo_Backup::record_marker(
1238 'post',
1239 $post_id,
1240 'aioseo_row_existed',
1241 $row_existed ? '1' : '0',
1242 $marker_created
1243 )) {
1244 return false;
1245 }
1246
1247 if ($marker_created) {
1248 $created[] = 'aioseo_row_existed';
1249 }
1250
1251 $columns = array_diff(array_keys($row), ['updated', 'created', 'post_id']);
1252 if (empty($columns)) {
1253 return true;
1254 }
1255
1256 $current = null;
1257 if ($row_existed) {
1258 $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`';
1259 // Column list is esc_sql()'d above; the table is a fixed literal.
1260 $current = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1261 $wpdb->prepare(
1262 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal
1263 "SELECT {$select} FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d",
1264 $post_id
1265 ),
1266 ARRAY_A
1267 );
1268
1269 // The row was there a moment ago, so a null answer now is a failed
1270 // read, not an empty row. Recording it as "every column was NULL"
1271 // would tell a later restore to delete values it should put back,
1272 // which is the exact loss this layer exists to prevent. No write is
1273 // happening, so the marker recorded above has to go too — a marker
1274 // left describing a write that never ran is a stale story.
1275 if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
1276 Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1277 return false;
1278 }
1279 }
1280
1281 foreach ($columns as $column) {
1282 // A missing row and a NULL column are the same thing to a restore:
1283 // there was no value here, so put nothing back.
1284 $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
1285
1286 $field = 'aioseo_' . $column;
1287
1288 // One unsaved column is enough to refuse the whole write: a half-original,
1289 // half-OTTO row is something no restore can unpick. The refusal also
1290 // means the caller writes nothing, so withdraw the marker and the
1291 // columns recorded so far — the same rule as the failed-insert path
1292 // in sync_aioseo(). Left behind, a row_existed='0' marker would let
1293 // a restore delete a row the customer creates later, and a NULL
1294 // column backup would blank a real value in it.
1295 if (!Metasync_Seo_Backup::backup_before_overwrite(
1296 'post',
1297 $post_id,
1298 $field,
1299 $row[$column],
1300 $current_value,
1301 $column_created
1302 )) {
1303 Metasync_Seo_Backup::discard_backups('post', $post_id, $created);
1304 return false;
1305 }
1306
1307 if ($column_created) {
1308 $created[] = $field;
1309 }
1310 }
1311
1312 return true;
1313 }
1314
1315 /**
1316 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
1317 *
1318 * @param int $post_id Post ID.
1319 * @param array $data Canonical key/value pairs.
1320 * @return bool True when at least one value-bearing field was written,
1321 * false when the table is missing, the write failed, or the
1322 * payload carried only robots directives. Robots are excluded
1323 * for the reason given on sync_yoast(), so the receipt means
1324 * the same thing for all three plugins.
1325 */
1326 private function sync_aioseo($post_id, array $data) {
1327 global $wpdb;
1328
1329 if (!$this->third_party_writes_allowed()) {
1330 return false;
1331 }
1332
1333 $table = $wpdb->prefix . 'aioseo_posts';
1334
1335 // Bail if the AIOSEO post table does not exist (plugin not initialised).
1336 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1337 if ($table_exists !== $table) {
1338 return false;
1339 }
1340
1341 $row = [];
1342 // Robots fill $row too, so a separate flag is needed: the row is still
1343 // written for a robots-only payload, it just is not a content sync.
1344 $wrote = false;
1345
1346 if (!empty($data['title'])) {
1347 $row['title'] = sanitize_text_field($data['title']);
1348 $wrote = true;
1349 }
1350 if (!empty($data['desc'])) {
1351 $row['description'] = sanitize_text_field($data['desc']);
1352 $wrote = true;
1353 }
1354 if (!empty($data['og_title'])) {
1355 $row['og_title'] = sanitize_text_field($data['og_title']);
1356 $wrote = true;
1357 }
1358 if (!empty($data['og_desc'])) {
1359 $row['og_description'] = sanitize_text_field($data['og_desc']);
1360 $wrote = true;
1361 }
1362 if (!empty($data['og_image'])) {
1363 $row['og_image_type'] = 'custom';
1364 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
1365 $wrote = true;
1366 }
1367 if (!empty($data['twitter_title'])) {
1368 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
1369 $wrote = true;
1370 }
1371 if (!empty($data['twitter_desc'])) {
1372 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
1373 $wrote = true;
1374 }
1375 if (!empty($data['twitter_card'])) {
1376 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
1377 if (in_array($data['twitter_card'], $valid_cards, true)) {
1378 $row['twitter_card'] = $data['twitter_card'];
1379 $wrote = true;
1380 }
1381 }
1382 if (!empty($data['canonical'])) {
1383 $row['canonical_url'] = esc_url_raw($data['canonical']);
1384 $wrote = true;
1385 }
1386
1387 // focus keyword as keyphrases JSON
1388 if (!empty($data['focus_keyword'])) {
1389 $row['keyphrases'] = wp_json_encode([
1390 'focus' => [
1391 'keyphrase' => sanitize_text_field($data['focus_keyword']),
1392 'score' => 0,
1393 'analysis' => new \stdClass(),
1394 ],
1395 'additional' => [],
1396 ]);
1397 $wrote = true;
1398 }
1399
1400 // Robots
1401 $has_robots = false;
1402 foreach (['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'] as $k) {
1403 if (array_key_exists($k, $data)) {
1404 $has_robots = true;
1405 break;
1406 }
1407 }
1408 if ($has_robots) {
1409 $row['robots_default'] = 0;
1410 if (array_key_exists('noindex', $data)) {
1411 $row['robots_noindex'] = $data['noindex'] ? 1 : 0;
1412 }
1413 if (array_key_exists('nofollow', $data)) {
1414 $row['robots_nofollow'] = $data['nofollow'] ? 1 : 0;
1415 }
1416 if (array_key_exists('noarchive', $data)) {
1417 $row['robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
1418 }
1419 if (array_key_exists('nosnippet', $data)) {
1420 $row['robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
1421 }
1422 if (array_key_exists('noimageindex', $data)) {
1423 $row['robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
1424 }
1425 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
1426 $row['robots_max_snippet'] = (int) $data['max_snippet'];
1427 }
1428 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
1429 $row['robots_max_videopreview'] = (int) $data['max_video_preview'];
1430 }
1431 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
1432 $allowed = ['none', 'standard', 'large'];
1433 if (in_array($data['max_image_preview'], $allowed, true)) {
1434 $row['robots_max_imagepreview'] = $data['max_image_preview'];
1435 }
1436 }
1437 }
1438
1439 if (empty($row)) {
1440 return false;
1441 }
1442
1443 $row['updated'] = current_time('mysql');
1444
1445 $existing_id = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1446 "SELECT id FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d",
1447 $post_id
1448 ));
1449
1450 // An unreadable probe cannot be treated as "no row". It would commit a
1451 // write-once row_existed='0' for a row AIOSEO really has -- which a
1452 // restore reads as licence to delete it -- and send an INSERT at a row
1453 // that already exists. Leave AIOSEO's row alone and let the next sync
1454 // record the truth.
1455 if (!Metasync_Seo_Backup::db_read_succeeded()) {
1456 return false;
1457 }
1458
1459 // No original saved means no write. Overwriting anyway is the data loss
1460 // this whole layer exists to prevent.
1461 if ($existing_id) {
1462 $backups_created = [];
1463 if (!$this->backup_aioseo_columns($post_id, $table, $row, true, $backups_created)) {
1464 return false;
1465 }
1466
1467 return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1468 }
1469
1470 $backups_created = [];
1471 if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1472 return false;
1473 }
1474
1475 // New row -- must include all NOT NULL columns with no defaults
1476 $row['post_id'] = $post_id;
1477 $row['created'] = current_time('mysql');
1478 $robot_defaults = [
1479 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
1480 'robots_noindex' => 0,
1481 'robots_nofollow' => 0,
1482 'robots_noarchive' => 0,
1483 'robots_nosnippet' => 0,
1484 'robots_noimageindex' => 0,
1485 'robots_noodp' => 0,
1486 'robots_notranslate' => 0,
1487 ];
1488 $row = array_merge($robot_defaults, $row);
1489
1490 $inserted = $wpdb->insert($table, $row); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors SEO meta into third-party plugin storage (Yoast indexables/AIOSEO tables) — no WordPress API for their schemas
1491
1492 // The row_existed='0' marker was recorded before the insert, because a
1493 // marker that will not save has to be able to veto the write. If the
1494 // insert then failed there is no row of ours, and leaving the marker
1495 // behind would let a restore delete a row the customer creates later.
1496 // The per-column backups beside it go too, or a column captured as NULL
1497 // for a row that never existed would blank a real value the customer
1498 // later puts in one.
1499 if ($inserted === false) {
1500 Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created);
1501 return false;
1502 }
1503
1504 return $wrote;
1505 }
1506
1507 // ------------------------------------------------------------------
1508 // Two-way sync: sidebar JSON ↔ legacy meta boxes
1509 // ------------------------------------------------------------------
1510
1511 /**
1512 * Mirror _metasync_robots_advanced JSON → legacy meta box keys.
1513 *
1514 * Called when the sidebar writes the JSON key so the classic-editor
1515 * meta boxes reflect the same values.
1516 *
1517 * @param int $post_id Post ID.
1518 * @param string $json_value Raw JSON string from _metasync_robots_advanced.
1519 */
1520 private function sync_to_legacy_meta($post_id, $json_value) {
1521 static $syncing_legacy = [];
1522 if (!empty($syncing_legacy[$post_id])) {
1523 return;
1524 }
1525 // Our own sync_legacy_to_json() write triggered this. The legacy meta
1526 // box values are the source of truth in that direction, and mirroring
1527 // back would drop any value this function treats as a default (-1 /
1528 // large), so bail out and leave the legacy keys alone.
1529 if (!empty($this->syncing_legacy_to_json[$post_id])) {
1530 return;
1531 }
1532 $syncing_legacy[$post_id] = true;
1533 // Block sync_legacy_to_json from running while we write legacy keys
1534 $this->syncing_json_to_legacy[$post_id] = true;
1535
1536 try {
1537 $robots = is_string($json_value) ? json_decode($json_value, true) : $json_value;
1538 if (!is_array($robots)) {
1539 return;
1540 }
1541
1542 // Build metasync_common_robots array
1543 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1544 if (!is_array($common)) {
1545 $common = [];
1546 }
1547 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1548 if (!empty($robots[$dir])) {
1549 $common[$dir] = $dir;
1550 } else {
1551 unset($common[$dir]);
1552 }
1553 }
1554 if (!empty($common)) {
1555 update_post_meta($post_id, 'metasync_common_robots', $common);
1556 } else {
1557 delete_post_meta($post_id, 'metasync_common_robots');
1558 }
1559
1560 // Build metasync_advance_robots array — skip default values to keep legacy clean
1561 $adv = [];
1562 if (isset($robots['max_snippet']) && $robots['max_snippet'] !== null && (int) $robots['max_snippet'] !== -1) {
1563 $adv['max-snippet'] = ['enable' => '1', 'length' => (string) $robots['max_snippet']];
1564 }
1565 if (isset($robots['max_image_preview']) && $robots['max_image_preview'] !== null && $robots['max_image_preview'] !== 'large') {
1566 $adv['max-image-preview'] = ['enable' => '1', 'length' => (string) $robots['max_image_preview']];
1567 }
1568 if (isset($robots['max_video_preview']) && $robots['max_video_preview'] !== null && (int) $robots['max_video_preview'] !== -1) {
1569 $adv['max-video-preview'] = ['enable' => '1', 'length' => (string) $robots['max_video_preview']];
1570 }
1571 if (!empty($adv)) {
1572 update_post_meta($post_id, 'metasync_advance_robots', $adv);
1573 } else {
1574 delete_post_meta($post_id, 'metasync_advance_robots');
1575 }
1576 } finally {
1577 unset($syncing_legacy[$post_id]);
1578 unset($this->syncing_json_to_legacy[$post_id]);
1579 }
1580 }
1581
1582 /**
1583 * Rebuild _metasync_robots_advanced JSON from legacy meta box keys.
1584 *
1585 * Called when the classic-editor meta boxes save metasync_common_robots
1586 * or metasync_advance_robots so the sidebar JSON stays in sync.
1587 *
1588 * @param int $post_id Post ID.
1589 */
1590 private function sync_legacy_to_json($post_id) {
1591 static $syncing_json = [];
1592 // If sync_to_legacy_meta is currently running, skip — it already wrote the correct JSON
1593 if (!empty($this->syncing_json_to_legacy[$post_id])) {
1594 return;
1595 }
1596 if (!empty($syncing_json[$post_id])) {
1597 return;
1598 }
1599 $syncing_json[$post_id] = true;
1600 // Block sync_to_legacy_meta from reacting to the JSON write below
1601 $this->syncing_legacy_to_json[$post_id] = true;
1602
1603 try {
1604 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1605 if (!is_array($common)) {
1606 $common = [];
1607 }
1608 $adv = get_post_meta($post_id, 'metasync_advance_robots', true);
1609 if (!is_array($adv)) {
1610 $adv = [];
1611 }
1612
1613 $json = [];
1614
1615 // Boolean directives from common_robots
1616 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1617 $json[$dir] = !empty($common[$dir]);
1618 }
1619
1620 // max-* directives from advance_robots
1621 if (!empty($adv['max-snippet']['enable'])) {
1622 $json['max_snippet'] = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1623 }
1624 if (!empty($adv['max-image-preview']['enable'])) {
1625 $json['max_image_preview'] = isset($adv['max-image-preview']['length']) ? (string) $adv['max-image-preview']['length'] : 'large';
1626 }
1627 if (!empty($adv['max-video-preview']['enable'])) {
1628 $json['max_video_preview'] = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1629 }
1630
1631 // Only write if there's something meaningful
1632 $has_value = false;
1633 foreach ($json as $v) {
1634 if ($v !== false && $v !== null) {
1635 $has_value = true;
1636 break;
1637 }
1638 }
1639
1640 if ($has_value) {
1641 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($json));
1642 } else {
1643 delete_post_meta($post_id, '_metasync_robots_advanced');
1644 }
1645 } finally {
1646 unset($syncing_json[$post_id]);
1647 unset($this->syncing_legacy_to_json[$post_id]);
1648 }
1649 }
1650 }
1651