PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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.0, at includes/class-metasync-plugin-sync.php

1,636 lines 59.4 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_robots_index',
66 '_metasync_robots_advanced',
67 '_metasync_og_title',
68 '_metasync_og_description',
69 '_metasync_og_image',
70 '_metasync_twitter_title',
71 '_metasync_twitter_description',
72 '_metasync_twitter_card',
73 '_metasync_canonical_url',
74 '_metasync_focus_keyword',
75 '_metasync_breadcrumb_title',
76 // OTTO volatile keys
77 '_metasync_otto_title',
78 '_metasync_otto_description',
79 '_metasync_otto_og_title',
80 '_metasync_otto_og_description',
81 '_metasync_otto_twitter_title',
82 '_metasync_otto_twitter_description',
83 '_metasync_otto_keywords',
84 ];
85
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 /**
113 * Get singleton instance.
114 *
115 * @return self
116 */
117 public static function get_instance() {
118 if (self::$instance === null) {
119 self::$instance = new self();
120 }
121 return self::$instance;
122 }
123
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 /**
152 * Sync MetaSync post meta to every active SEO plugin.
153 *
154 * When $fields is non-empty only those canonical keys are synced.
155 * When empty a full sync of all canonical keys is performed.
156 *
157 * @param int $post_id Post ID.
158 * @param array $fields Optional subset of canonical key/value pairs to sync.
159 * @return array Results keyed by plugin: ['yoast'=>bool,'rankmath'=>bool,'aioseo'=>bool].
160 */
161 public function sync_post($post_id, array $fields = []) {
162 static $syncing = [];
163
164 if (!empty($syncing[$post_id])) {
165 return [];
166 }
167 $syncing[$post_id] = true;
168
169 try {
170 $results = [];
171
172 if ($post_id <= 0) {
173 return $results;
174 }
175
176 $data = $this->collect_post_data($post_id, $fields);
177
178 if (empty($data)) {
179 return $results;
180 }
181
182 if ($this->is_yoast_active()) {
183 $results['yoast'] = $this->sync_yoast((int) $post_id, $data);
184 }
185
186 if ($this->is_rankmath_active()) {
187 $results['rankmath'] = $this->sync_rankmath((int) $post_id, $data);
188 }
189
190 if ($this->is_aioseo_active()) {
191 $results['aioseo'] = $this->sync_aioseo((int) $post_id, $data);
192 }
193
194 // Write sync timestamp
195 $ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
196 $sync_data = !empty($ts) ? json_decode($ts, true) : [];
197 if (!is_array($sync_data)) {
198 $sync_data = [];
199 }
200 if ($results['yoast'] ?? false) {
201 $sync_data['yoast'] = gmdate('c');
202 }
203 if ($results['rankmath'] ?? false) {
204 $sync_data['rankmath'] = gmdate('c');
205 }
206 if ($results['aioseo'] ?? false) {
207 $sync_data['aioseo'] = gmdate('c');
208 }
209 if (!empty($sync_data)) {
210 update_post_meta($post_id, '_metasync_plugin_sync_ts', wp_json_encode($sync_data));
211 }
212
213 return $results;
214 } finally {
215 unset($syncing[$post_id]);
216 }
217 }
218
219 /**
220 * Hook handler for updated_post_meta / added_post_meta.
221 *
222 * Fires sync_post when a watched MetaSync meta key is written.
223 *
224 * @param int $meta_id Meta row ID (unused).
225 * @param int $post_id Post ID.
226 * @param string $meta_key Meta key being written.
227 * @param mixed $meta_value Meta value being written.
228 */
229 public function on_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
230 $watched = [
231 // Sidebar / persisted keys
232 '_metasync_seo_title' => 'title',
233 '_metasync_seo_desc' => 'desc',
234 '_metasync_metatitle' => 'title',
235 '_metasync_metadesc' => 'desc',
236 '_metasync_robots_index' => 'noindex',
237 '_metasync_og_title' => 'og_title',
238 '_metasync_og_description' => 'og_desc',
239 '_metasync_og_image' => 'og_image',
240 '_metasync_twitter_title' => 'twitter_title',
241 '_metasync_twitter_description' => 'twitter_desc',
242 '_metasync_twitter_card' => 'twitter_card',
243 '_metasync_canonical_url' => 'canonical',
244 '_metasync_focus_keyword' => 'focus_keyword',
245 '_metasync_breadcrumb_title' => 'breadcrumb_title',
246 '_metasync_robots_advanced' => '_robots_advanced_json',
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.
250 '_metasync_otto_title' => 'title',
251 '_metasync_otto_description' => 'desc',
252 '_metasync_otto_og_title' => 'og_title',
253 '_metasync_otto_og_description' => 'og_desc',
254 '_metasync_otto_twitter_title' => 'twitter_title',
255 '_metasync_otto_twitter_description' => 'twitter_desc',
256 '_metasync_otto_keywords' => 'focus_keyword',
257 // Legacy meta box keys → rebuild JSON
258 'metasync_common_robots' => '_legacy_robots_to_json',
259 'metasync_advance_robots' => '_legacy_robots_to_json',
260 ];
261
262 if (!isset($watched[$meta_key])) {
263 return;
264 }
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
275 $canonical_key = $watched[$meta_key];
276
277 // JSON key -- do a full sync + mirror to legacy meta boxes
278 if ($canonical_key === '_robots_advanced_json') {
279 $this->sync_post((int) $post_id);
280 $this->sync_to_legacy_meta((int) $post_id, $meta_value);
281 // Re-sync at shutdown only when Yoast is active — Yoast's indexable
282 // watcher can overwrite our values during the same request.
283 if ($this->is_yoast_active()) {
284 $sync_instance = $this;
285 $sync_post_id = (int) $post_id;
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 }
295 $sync_instance->sync_post($sync_post_id);
296 }, 0);
297 }
298 return;
299 }
300
301 // Legacy meta box → rebuild _metasync_robots_advanced JSON
302 if ($canonical_key === '_legacy_robots_to_json') {
303 $this->sync_legacy_to_json((int) $post_id);
304 return;
305 }
306
307 // For noindex: convert 'noindex' string to bool
308 if ($canonical_key === 'noindex') {
309 $value = ($meta_value === 'noindex');
310 } else {
311 $value = $meta_value;
312 }
313
314 $this->sync_post(
315 (int) $post_id,
316 [$canonical_key => $value]
317 );
318 }
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
346 // ------------------------------------------------------------------
347 // Data collection
348 // ------------------------------------------------------------------
349
350 /**
351 * Collect canonical SEO data from all MetaSync post meta.
352 *
353 * Reads all meta in one get_post_custom() call for performance.
354 * When $fields is non-empty, the result is filtered to only those keys.
355 *
356 * @param int $post_id Post ID.
357 * @param array $fields Optional pre-resolved canonical key/value pairs.
358 * @return array Canonical data array.
359 */
360 private function collect_post_data($post_id, array $fields = []) {
361 // If caller already resolved specific fields, return them directly.
362 // Canonical still gets validated — this branch serves the
363 // updated_post_meta fast-path, which would otherwise mirror a raw
364 // (possibly corrupted) value into third-party storage.
365 if (!empty($fields)) {
366 if (array_key_exists('canonical', $fields)) {
367 $fields['canonical'] = Metasync_Canonical_Sanitizer::sanitize($fields['canonical']);
368 if ($fields['canonical'] === '') {
369 unset($fields['canonical']);
370 }
371 }
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);
390 }
391
392 $all_meta = get_post_custom($post_id);
393
394 $get = function ($key) use ($all_meta, $post_id) {
395 if (!isset($all_meta[$key])) {
396 return '';
397 }
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;
446 };
447
448 $data = [];
449
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.
461 $first = function (...$keys) use ($get) {
462 foreach ($keys as $key) {
463 if (!$this->otto_persistence_allows($key)) {
464 continue;
465 }
466 $val = $get($key);
467 if (!empty($val)) {
468 return $val;
469 }
470 }
471 return '';
472 };
473
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));
484
485 $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION));
486
487 // Robots directives: check _metasync_robots_advanced JSON first,
488 // then fall back to metasync_common_robots array + metasync_advance_robots array
489 $robots_json_raw = $get('_metasync_robots_advanced');
490 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
491
492 if (is_array($robots_json)) {
493 $data['noindex'] = $this->resolve_synced_noindex(
494 $robots_json,
495 $get('_metasync_robots_index'),
496 $get('metasync_common_robots')
497 );
498 $data['nofollow'] = !empty($robots_json['nofollow']);
499 $data['noarchive'] = !empty($robots_json['noarchive']);
500 $data['nosnippet'] = !empty($robots_json['nosnippet']);
501 $data['noimageindex'] = !empty($robots_json['noimageindex']);
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);
503 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
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);
505 } else {
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 );
513
514 // Common robots array (serialized)
515 $common_raw = $get('metasync_common_robots');
516 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
517 if (!is_array($common_robots)) {
518 $common_robots = [];
519 }
520
521 $data['nofollow'] = !empty($common_robots['nofollow']);
522 $data['noarchive'] = !empty($common_robots['noarchive']);
523 $data['nosnippet'] = !empty($common_robots['nosnippet']);
524 $data['noimageindex'] = !empty($common_robots['noimageindex']);
525
526 // Advance robots array (serialized)
527 $adv_raw = $get('metasync_advance_robots');
528 $adv_robots = !empty($adv_raw) ? maybe_unserialize($adv_raw) : [];
529 if (!is_array($adv_robots)) {
530 $adv_robots = [];
531 }
532
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;
547 }
548
549 // Social / OG: persisted > volatile OTTO
550 $data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title');
551 $data['og_desc'] = $first('_metasync_og_description', '_metasync_otto_og_description');
552 $data['og_image'] = $get('_metasync_og_image');
553
554 // Twitter: persisted > volatile OTTO
555 $data['twitter_title'] = $first('_metasync_twitter_title', '_metasync_otto_twitter_title');
556 $data['twitter_desc'] = $first('_metasync_twitter_description', '_metasync_otto_twitter_description');
557 $data['twitter_card'] = $get('_metasync_twitter_card');
558
559 // Canonical, focus keyword, breadcrumb
560 // Canonical is validated at the source so a corrupted value ("Array")
561 // never propagates into Yoast/RankMath/AIOSEO storage.
562 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
563 $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
564 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
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
615 return $data;
616 }
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
648 // ------------------------------------------------------------------
649 // Plugin detectors
650 // ------------------------------------------------------------------
651
652 /**
653 * Check whether Yoast SEO (free or premium) is active.
654 *
655 * @return bool
656 */
657 private function is_yoast_active() {
658 $this->ensure_plugin_api();
659 return is_plugin_active('wordpress-seo/wp-seo.php')
660 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
661 }
662
663 /**
664 * Check whether Rank Math SEO is active.
665 *
666 * @return bool
667 */
668 private function is_rankmath_active() {
669 $this->ensure_plugin_api();
670 return is_plugin_active('seo-by-rank-math/rank-math.php')
671 || is_plugin_active('seo-by-rankmath/rank-math.php');
672 }
673
674 /**
675 * Check whether AIOSEO (free or pro) is active.
676 *
677 * @return bool
678 */
679 private function is_aioseo_active() {
680 $this->ensure_plugin_api();
681 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
682 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
683 }
684
685 /**
686 * Ensure is_plugin_active() is loaded on the frontend.
687 */
688 private function ensure_plugin_api() {
689 if (!function_exists('is_plugin_active')) {
690 require_once ABSPATH . 'wp-admin/includes/plugin.php';
691 }
692 }
693
694 // ------------------------------------------------------------------
695 // Per-plugin sync
696 // ------------------------------------------------------------------
697
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 /**
731 * Mirror canonical data into Yoast post meta and indexable cache.
732 *
733 * @param int $post_id Post ID.
734 * @param array $data Canonical key/value pairs.
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.
748 */
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
756 // title
757 if (!empty($data['title'])) {
758 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_title', (string) $data['title']);
759 }
760
761 // description -- strip newlines first
762 if (!empty($data['desc'])) {
763 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
764 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote;
765 }
766
767 // noindex: '0'=default, '1'=noindex, '2'=index
768 if (array_key_exists('noindex', $data)) {
769 $val = $data['noindex'] ? '1' : '2';
770 $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
771 }
772
773 // nofollow: '0'=follow, '1'=nofollow
774 if (array_key_exists('nofollow', $data)) {
775 $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
776 }
777
778 // advanced robots: comma-separated NO spaces
779 $adv = [];
780 if (!empty($data['noarchive'])) {
781 $adv[] = 'noarchive';
782 }
783 if (!empty($data['nosnippet'])) {
784 $adv[] = 'nosnippet';
785 }
786 if (!empty($data['noimageindex'])) {
787 $adv[] = 'noimageindex';
788 }
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));
793
794 // OG
795 if (!empty($data['og_title'])) {
796 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']) || $wrote;
797 }
798 if (!empty($data['og_desc'])) {
799 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']) || $wrote;
800 }
801 if (!empty($data['og_image'])) {
802 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])) || $wrote;
803 }
804
805 // Twitter
806 if (!empty($data['twitter_title'])) {
807 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']) || $wrote;
808 }
809 if (!empty($data['twitter_desc'])) {
810 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']) || $wrote;
811 }
812
813 // Canonical, focus keyword, breadcrumb
814 if (!empty($data['canonical'])) {
815 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])) || $wrote;
816 }
817 if (!empty($data['focus_keyword'])) {
818 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']) || $wrote;
819 }
820 if (!empty($data['breadcrumb_title'])) {
821 $wrote = $this->write_post_field($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']) || $wrote;
822 }
823
824 // Update wp_yoast_indexable cache row for immediate effect
825 global $wpdb;
826 $indexable_table = $wpdb->prefix . 'yoast_indexable';
827
828 $updates = [];
829 if (!empty($data['title'])) {
830 $updates['title'] = mb_substr($data['title'], 0, 191);
831 }
832 if (!empty($data['desc'])) {
833 $updates['description'] = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
834 }
835 if (array_key_exists('noindex', $data)) {
836 $updates['is_robots_noindex'] = $data['noindex'] ? 1 : 0;
837 }
838 if (array_key_exists('nofollow', $data)) {
839 $updates['is_robots_nofollow'] = $data['nofollow'] ? 1 : 0;
840 }
841 if (array_key_exists('noarchive', $data)) {
842 $updates['is_robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
843 }
844 if (array_key_exists('nosnippet', $data)) {
845 $updates['is_robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
846 }
847 if (array_key_exists('noimageindex', $data)) {
848 $updates['is_robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
849 }
850 if (!empty($data['og_title'])) {
851 $updates['open_graph_title'] = mb_substr($data['og_title'], 0, 191);
852 }
853 if (!empty($data['og_image'])) {
854 $updates['open_graph_image'] = $data['og_image'];
855 }
856 if (!empty($data['twitter_title'])) {
857 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
858 }
859 if (!empty($data['twitter_card'])) {
860 // twitter_card column only exists in newer Yoast versions; skip if absent
861 $col_check = $wpdb->get_var($wpdb->prepare(
862 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
863 DB_NAME,
864 $indexable_table
865 ));
866 if ($col_check) {
867 $updates['twitter_card'] = $data['twitter_card'];
868 }
869 }
870 if (!empty($data['canonical'])) {
871 $updates['canonical'] = $data['canonical'];
872 }
873 if (!empty($data['focus_keyword'])) {
874 $updates['primary_focus_keyword'] = mb_substr($data['focus_keyword'], 0, 191);
875 }
876 if (!empty($data['breadcrumb_title'])) {
877 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
878 }
879
880 if (!empty($updates)) {
881 $row_exists = $wpdb->get_var($wpdb->prepare(
882 "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
883 $post_id
884 ));
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
909 if ($row_exists) {
910 $wpdb->update(
911 $indexable_table,
912 $updates,
913 ['object_id' => $post_id, 'object_type' => 'post']
914 );
915 } else {
916 $post = get_post($post_id);
917 $insert = array_merge([
918 'object_id' => $post_id,
919 'object_type' => 'post',
920 'object_sub_type' => $post ? $post->post_type : 'post',
921 'post_status' => $post ? $post->post_status : 'publish',
922 'author_id' => $post ? (int) $post->post_author : 0,
923 'is_robots_noindex' => 0,
924 'is_robots_nofollow' => 0,
925 'is_robots_noarchive' => 0,
926 'is_robots_nosnippet' => 0,
927 'is_robots_noimageindex' => 0,
928 'is_cornerstone' => 0,
929 'created_at' => current_time('mysql'),
930 'updated_at' => current_time('mysql'),
931 ], $updates);
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 }
952 }
953 }
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
1061 return true;
1062 }
1063
1064 /**
1065 * Mirror canonical data into Rank Math post meta.
1066 *
1067 * @param int $post_id Post ID.
1068 * @param array $data Canonical key/value pairs.
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().
1072 */
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
1080 // title, desc
1081 if (!empty($data['title'])) {
1082 $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']);
1083 }
1084 if (!empty($data['desc'])) {
1085 $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote;
1086 }
1087
1088 // robots: PHP indexed array
1089 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
1090 $existing = get_post_meta($post_id, 'rank_math_robots', true);
1091 $robots = is_array($existing) ? $existing : [];
1092 $robots = array_values(array_diff($robots, ['index', 'noindex', 'follow', 'nofollow']));
1093 if (array_key_exists('noindex', $data)) {
1094 $robots[] = $data['noindex'] ? 'noindex' : 'index';
1095 }
1096 if (array_key_exists('nofollow', $data)) {
1097 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
1098 }
1099 $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1100 }
1101
1102 // Advanced robots: max-* go into rank_math_advanced_robots
1103 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
1104 $has_adv = false;
1105 foreach ($adv_keys as $k) {
1106 if (array_key_exists($k, $data)) {
1107 $has_adv = true;
1108 break;
1109 }
1110 }
1111 if ($has_adv) {
1112 $existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true);
1113 $adv = is_array($existing_adv) ? $existing_adv : [];
1114 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
1115 $val = (int) $data['max_snippet'];
1116 $adv['max-snippet'] = (string) $val;
1117 }
1118 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
1119 $allowed = ['none', 'standard', 'large'];
1120 if (in_array($data['max_image_preview'], $allowed, true)) {
1121 $adv['max-image-preview'] = (string) $data['max_image_preview'];
1122 }
1123 }
1124 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
1125 $val = (int) $data['max_video_preview'];
1126 $adv['max-video-preview'] = (string) $val;
1127 }
1128 if (!empty($adv)) {
1129 $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv);
1130 }
1131 }
1132
1133 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
1134 if (array_key_exists('noarchive', $data) || array_key_exists('nosnippet', $data) || array_key_exists('noimageindex', $data)) {
1135 $existing = get_post_meta($post_id, 'rank_math_robots', true);
1136 $robots = is_array($existing) ? $existing : [];
1137 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1138 if (!array_key_exists($dir, $data)) {
1139 continue;
1140 }
1141 $robots = array_values(array_diff($robots, [$dir]));
1142 if (!empty($data[$dir])) {
1143 $robots[] = $dir;
1144 }
1145 }
1146 $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots)));
1147 }
1148
1149 // OG
1150 if (!empty($data['og_title'])) {
1151 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote;
1152 }
1153 if (!empty($data['og_desc'])) {
1154 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote;
1155 }
1156 if (!empty($data['og_image'])) {
1157 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])) || $wrote;
1158 $img_id = attachment_url_to_postid($data['og_image']);
1159 if ($img_id) {
1160 $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image_id', $img_id) || $wrote;
1161 }
1162 }
1163
1164 // Twitter
1165 if (!empty($data['twitter_title'])) {
1166 $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote;
1167 }
1168 if (!empty($data['twitter_desc'])) {
1169 $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote;
1170 }
1171 if (!empty($data['twitter_card'])) {
1172 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
1173 if (in_array($data['twitter_card'], $valid_cards, true)) {
1174 $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote;
1175 }
1176 }
1177
1178 // Canonical, focus keyword, breadcrumb
1179 if (!empty($data['canonical'])) {
1180 $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote;
1181 }
1182 if (!empty($data['focus_keyword'])) {
1183 $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote;
1184 }
1185 if (!empty($data['breadcrumb_title'])) {
1186 $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote;
1187 }
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
1297 return true;
1298 }
1299
1300 /**
1301 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
1302 *
1303 * @param int $post_id Post ID.
1304 * @param array $data Canonical key/value pairs.
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.
1310 */
1311 private function sync_aioseo($post_id, array $data) {
1312 global $wpdb;
1313
1314 if (!$this->third_party_writes_allowed()) {
1315 return false;
1316 }
1317
1318 $table = $wpdb->prefix . 'aioseo_posts';
1319
1320 // Bail if the AIOSEO post table does not exist (plugin not initialised).
1321 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
1322 if ($table_exists !== $table) {
1323 return false;
1324 }
1325
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;
1330
1331 if (!empty($data['title'])) {
1332 $row['title'] = sanitize_text_field($data['title']);
1333 $wrote = true;
1334 }
1335 if (!empty($data['desc'])) {
1336 $row['description'] = sanitize_text_field($data['desc']);
1337 $wrote = true;
1338 }
1339 if (!empty($data['og_title'])) {
1340 $row['og_title'] = sanitize_text_field($data['og_title']);
1341 $wrote = true;
1342 }
1343 if (!empty($data['og_desc'])) {
1344 $row['og_description'] = sanitize_text_field($data['og_desc']);
1345 $wrote = true;
1346 }
1347 if (!empty($data['og_image'])) {
1348 $row['og_image_type'] = 'custom';
1349 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
1350 $wrote = true;
1351 }
1352 if (!empty($data['twitter_title'])) {
1353 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
1354 $wrote = true;
1355 }
1356 if (!empty($data['twitter_desc'])) {
1357 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
1358 $wrote = true;
1359 }
1360 if (!empty($data['twitter_card'])) {
1361 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
1362 if (in_array($data['twitter_card'], $valid_cards, true)) {
1363 $row['twitter_card'] = $data['twitter_card'];
1364 $wrote = true;
1365 }
1366 }
1367 if (!empty($data['canonical'])) {
1368 $row['canonical_url'] = esc_url_raw($data['canonical']);
1369 $wrote = true;
1370 }
1371
1372 // focus keyword as keyphrases JSON
1373 if (!empty($data['focus_keyword'])) {
1374 $row['keyphrases'] = wp_json_encode([
1375 'focus' => [
1376 'keyphrase' => sanitize_text_field($data['focus_keyword']),
1377 'score' => 0,
1378 'analysis' => new \stdClass(),
1379 ],
1380 'additional' => [],
1381 ]);
1382 $wrote = true;
1383 }
1384
1385 // Robots
1386 $has_robots = false;
1387 foreach (['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'] as $k) {
1388 if (array_key_exists($k, $data)) {
1389 $has_robots = true;
1390 break;
1391 }
1392 }
1393 if ($has_robots) {
1394 $row['robots_default'] = 0;
1395 if (array_key_exists('noindex', $data)) {
1396 $row['robots_noindex'] = $data['noindex'] ? 1 : 0;
1397 }
1398 if (array_key_exists('nofollow', $data)) {
1399 $row['robots_nofollow'] = $data['nofollow'] ? 1 : 0;
1400 }
1401 if (array_key_exists('noarchive', $data)) {
1402 $row['robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
1403 }
1404 if (array_key_exists('nosnippet', $data)) {
1405 $row['robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
1406 }
1407 if (array_key_exists('noimageindex', $data)) {
1408 $row['robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
1409 }
1410 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
1411 $row['robots_max_snippet'] = (int) $data['max_snippet'];
1412 }
1413 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
1414 $row['robots_max_videopreview'] = (int) $data['max_video_preview'];
1415 }
1416 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
1417 $allowed = ['none', 'standard', 'large'];
1418 if (in_array($data['max_image_preview'], $allowed, true)) {
1419 $row['robots_max_imagepreview'] = $data['max_image_preview'];
1420 }
1421 }
1422 }
1423
1424 if (empty($row)) {
1425 return false;
1426 }
1427
1428 $row['updated'] = current_time('mysql');
1429
1430 $existing_id = $wpdb->get_var($wpdb->prepare(
1431 "SELECT id FROM {$table} WHERE post_id = %d",
1432 $post_id
1433 ));
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.
1446 if ($existing_id) {
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;
1453 }
1454
1455 $backups_created = [];
1456 if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) {
1457 return false;
1458 }
1459
1460 // New row -- must include all NOT NULL columns with no defaults
1461 $row['post_id'] = $post_id;
1462 $row['created'] = current_time('mysql');
1463 $robot_defaults = [
1464 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
1465 'robots_noindex' => 0,
1466 'robots_nofollow' => 0,
1467 'robots_noarchive' => 0,
1468 'robots_nosnippet' => 0,
1469 'robots_noimageindex' => 0,
1470 'robots_noodp' => 0,
1471 'robots_notranslate' => 0,
1472 ];
1473 $row = array_merge($robot_defaults, $row);
1474
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;
1490 }
1491
1492 // ------------------------------------------------------------------
1493 // Two-way sync: sidebar JSON ↔ legacy meta boxes
1494 // ------------------------------------------------------------------
1495
1496 /**
1497 * Mirror _metasync_robots_advanced JSON → legacy meta box keys.
1498 *
1499 * Called when the sidebar writes the JSON key so the classic-editor
1500 * meta boxes reflect the same values.
1501 *
1502 * @param int $post_id Post ID.
1503 * @param string $json_value Raw JSON string from _metasync_robots_advanced.
1504 */
1505 private function sync_to_legacy_meta($post_id, $json_value) {
1506 static $syncing_legacy = [];
1507 if (!empty($syncing_legacy[$post_id])) {
1508 return;
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 }
1517 $syncing_legacy[$post_id] = true;
1518 // Block sync_legacy_to_json from running while we write legacy keys
1519 $this->syncing_json_to_legacy[$post_id] = true;
1520
1521 try {
1522 $robots = is_string($json_value) ? json_decode($json_value, true) : $json_value;
1523 if (!is_array($robots)) {
1524 return;
1525 }
1526
1527 // Build metasync_common_robots array
1528 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1529 if (!is_array($common)) {
1530 $common = [];
1531 }
1532 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1533 if (!empty($robots[$dir])) {
1534 $common[$dir] = $dir;
1535 } else {
1536 unset($common[$dir]);
1537 }
1538 }
1539 if (!empty($common)) {
1540 update_post_meta($post_id, 'metasync_common_robots', $common);
1541 } else {
1542 delete_post_meta($post_id, 'metasync_common_robots');
1543 }
1544
1545 // Build metasync_advance_robots array — skip default values to keep legacy clean
1546 $adv = [];
1547 if (isset($robots['max_snippet']) && $robots['max_snippet'] !== null && (int) $robots['max_snippet'] !== -1) {
1548 $adv['max-snippet'] = ['enable' => '1', 'length' => (string) $robots['max_snippet']];
1549 }
1550 if (isset($robots['max_image_preview']) && $robots['max_image_preview'] !== null && $robots['max_image_preview'] !== 'large') {
1551 $adv['max-image-preview'] = ['enable' => '1', 'length' => (string) $robots['max_image_preview']];
1552 }
1553 if (isset($robots['max_video_preview']) && $robots['max_video_preview'] !== null && (int) $robots['max_video_preview'] !== -1) {
1554 $adv['max-video-preview'] = ['enable' => '1', 'length' => (string) $robots['max_video_preview']];
1555 }
1556 if (!empty($adv)) {
1557 update_post_meta($post_id, 'metasync_advance_robots', $adv);
1558 } else {
1559 delete_post_meta($post_id, 'metasync_advance_robots');
1560 }
1561 } finally {
1562 unset($syncing_legacy[$post_id]);
1563 unset($this->syncing_json_to_legacy[$post_id]);
1564 }
1565 }
1566
1567 /**
1568 * Rebuild _metasync_robots_advanced JSON from legacy meta box keys.
1569 *
1570 * Called when the classic-editor meta boxes save metasync_common_robots
1571 * or metasync_advance_robots so the sidebar JSON stays in sync.
1572 *
1573 * @param int $post_id Post ID.
1574 */
1575 private function sync_legacy_to_json($post_id) {
1576 static $syncing_json = [];
1577 // If sync_to_legacy_meta is currently running, skip — it already wrote the correct JSON
1578 if (!empty($this->syncing_json_to_legacy[$post_id])) {
1579 return;
1580 }
1581 if (!empty($syncing_json[$post_id])) {
1582 return;
1583 }
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;
1587
1588 try {
1589 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1590 if (!is_array($common)) {
1591 $common = [];
1592 }
1593 $adv = get_post_meta($post_id, 'metasync_advance_robots', true);
1594 if (!is_array($adv)) {
1595 $adv = [];
1596 }
1597
1598 $json = [];
1599
1600 // Boolean directives from common_robots
1601 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1602 $json[$dir] = !empty($common[$dir]);
1603 }
1604
1605 // max-* directives from advance_robots
1606 if (!empty($adv['max-snippet']['enable'])) {
1607 $json['max_snippet'] = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1608 }
1609 if (!empty($adv['max-image-preview']['enable'])) {
1610 $json['max_image_preview'] = isset($adv['max-image-preview']['length']) ? (string) $adv['max-image-preview']['length'] : 'large';
1611 }
1612 if (!empty($adv['max-video-preview']['enable'])) {
1613 $json['max_video_preview'] = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1614 }
1615
1616 // Only write if there's something meaningful
1617 $has_value = false;
1618 foreach ($json as $v) {
1619 if ($v !== false && $v !== null) {
1620 $has_value = true;
1621 break;
1622 }
1623 }
1624
1625 if ($has_value) {
1626 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($json));
1627 } else {
1628 delete_post_meta($post_id, '_metasync_robots_advanced');
1629 }
1630 } finally {
1631 unset($syncing_json[$post_id]);
1632 unset($this->syncing_legacy_to_json[$post_id]);
1633 }
1634 }
1635 }
1636