PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-plugin-sync.php

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

1,322 lines 46.9 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 * Mirror canonical data into Yoast post meta and indexable cache.
700 *
701 * @param int $post_id Post ID.
702 * @param array $data Canonical key/value pairs.
703 * @return bool True when at least one value-bearing field was mirrored,
704 * false when the payload carried nothing to write. The caller
705 * stamps `_metasync_plugin_sync_ts` from this, so it must
706 * describe what was written and not merely that dispatch was
707 * reached.
708 *
709 * Robots directives are excluded from that answer on purpose.
710 * collect_post_data() synthesises noindex/nofollow on every
711 * payload — absent any stored value they resolve to plain
712 * index/follow — so they are written on every sync and say
713 * nothing about whether a canonical value existed to mirror.
714 * Counting them would make the result unconditionally true,
715 * which is the bug this return contract exists to fix.
716 */
717 private function sync_yoast($post_id, array $data) {
718 $wrote = false;
719
720 // title
721 if (!empty($data['title'])) {
722 update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
723 $wrote = true;
724 }
725
726 // description -- strip newlines first
727 if (!empty($data['desc'])) {
728 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
729 update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
730 $wrote = true;
731 }
732
733 // noindex: '0'=default, '1'=noindex, '2'=index
734 if (array_key_exists('noindex', $data)) {
735 $val = $data['noindex'] ? '1' : '2';
736 update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
737 }
738
739 // nofollow: '0'=follow, '1'=nofollow
740 if (array_key_exists('nofollow', $data)) {
741 update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
742 }
743
744 // advanced robots: comma-separated NO spaces
745 $adv = [];
746 if (!empty($data['noarchive'])) {
747 $adv[] = 'noarchive';
748 }
749 if (!empty($data['nosnippet'])) {
750 $adv[] = 'nosnippet';
751 }
752 if (!empty($data['noimageindex'])) {
753 $adv[] = 'noimageindex';
754 }
755 // Written unconditionally so clearing the last directive clears the
756 // field. Like the other robots writes it carries no canonical value,
757 // so it does not make this a successful sync.
758 update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
759
760 // OG
761 if (!empty($data['og_title'])) {
762 update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
763 $wrote = true;
764 }
765 if (!empty($data['og_desc'])) {
766 update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
767 $wrote = true;
768 }
769 if (!empty($data['og_image'])) {
770 update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
771 $wrote = true;
772 }
773
774 // Twitter
775 if (!empty($data['twitter_title'])) {
776 update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
777 $wrote = true;
778 }
779 if (!empty($data['twitter_desc'])) {
780 update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
781 $wrote = true;
782 }
783
784 // Canonical, focus keyword, breadcrumb
785 if (!empty($data['canonical'])) {
786 update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
787 $wrote = true;
788 }
789 if (!empty($data['focus_keyword'])) {
790 update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
791 $wrote = true;
792 }
793 if (!empty($data['breadcrumb_title'])) {
794 update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
795 $wrote = true;
796 }
797
798 // Update wp_yoast_indexable cache row for immediate effect
799 global $wpdb;
800 $indexable_table = $wpdb->prefix . 'yoast_indexable';
801
802 $updates = [];
803 if (!empty($data['title'])) {
804 $updates['title'] = mb_substr($data['title'], 0, 191);
805 }
806 if (!empty($data['desc'])) {
807 $updates['description'] = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
808 }
809 if (array_key_exists('noindex', $data)) {
810 $updates['is_robots_noindex'] = $data['noindex'] ? 1 : 0;
811 }
812 if (array_key_exists('nofollow', $data)) {
813 $updates['is_robots_nofollow'] = $data['nofollow'] ? 1 : 0;
814 }
815 if (array_key_exists('noarchive', $data)) {
816 $updates['is_robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
817 }
818 if (array_key_exists('nosnippet', $data)) {
819 $updates['is_robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
820 }
821 if (array_key_exists('noimageindex', $data)) {
822 $updates['is_robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
823 }
824 if (!empty($data['og_title'])) {
825 $updates['open_graph_title'] = mb_substr($data['og_title'], 0, 191);
826 }
827 if (!empty($data['og_image'])) {
828 $updates['open_graph_image'] = $data['og_image'];
829 }
830 if (!empty($data['twitter_title'])) {
831 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
832 }
833 if (!empty($data['twitter_card'])) {
834 // twitter_card column only exists in newer Yoast versions; skip if absent
835 $col_check = $wpdb->get_var($wpdb->prepare(
836 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
837 DB_NAME,
838 $indexable_table
839 ));
840 if ($col_check) {
841 $updates['twitter_card'] = $data['twitter_card'];
842 }
843 }
844 if (!empty($data['canonical'])) {
845 $updates['canonical'] = $data['canonical'];
846 }
847 if (!empty($data['focus_keyword'])) {
848 $updates['primary_focus_keyword'] = mb_substr($data['focus_keyword'], 0, 191);
849 }
850 if (!empty($data['breadcrumb_title'])) {
851 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
852 }
853
854 if (!empty($updates)) {
855 $row_exists = $wpdb->get_var($wpdb->prepare(
856 "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
857 $post_id
858 ));
859
860 if ($row_exists) {
861 $wpdb->update(
862 $indexable_table,
863 $updates,
864 ['object_id' => $post_id, 'object_type' => 'post']
865 );
866 } else {
867 $post = get_post($post_id);
868 $insert = array_merge([
869 'object_id' => $post_id,
870 'object_type' => 'post',
871 'object_sub_type' => $post ? $post->post_type : 'post',
872 'post_status' => $post ? $post->post_status : 'publish',
873 'author_id' => $post ? (int) $post->post_author : 0,
874 'is_robots_noindex' => 0,
875 'is_robots_nofollow' => 0,
876 'is_robots_noarchive' => 0,
877 'is_robots_nosnippet' => 0,
878 'is_robots_noimageindex' => 0,
879 'is_cornerstone' => 0,
880 'created_at' => current_time('mysql'),
881 'updated_at' => current_time('mysql'),
882 ], $updates);
883 $wpdb->insert($indexable_table, $insert);
884 }
885 }
886
887 return $wrote;
888 }
889
890 /**
891 * Mirror canonical data into Rank Math post meta.
892 *
893 * @param int $post_id Post ID.
894 * @param array $data Canonical key/value pairs.
895 * @return bool True when at least one value-bearing field was mirrored,
896 * false when the payload carried nothing to write. Robots
897 * directives are excluded for the reason given on sync_yoast().
898 */
899 private function sync_rankmath($post_id, array $data) {
900 $wrote = false;
901
902 // title, desc
903 if (!empty($data['title'])) {
904 update_post_meta($post_id, 'rank_math_title', $data['title']);
905 $wrote = true;
906 }
907 if (!empty($data['desc'])) {
908 update_post_meta($post_id, 'rank_math_description', $data['desc']);
909 $wrote = true;
910 }
911
912 // robots: PHP indexed array
913 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
914 $existing = get_post_meta($post_id, 'rank_math_robots', true);
915 $robots = is_array($existing) ? $existing : [];
916 $robots = array_values(array_diff($robots, ['index', 'noindex', 'follow', 'nofollow']));
917 if (array_key_exists('noindex', $data)) {
918 $robots[] = $data['noindex'] ? 'noindex' : 'index';
919 }
920 if (array_key_exists('nofollow', $data)) {
921 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
922 }
923 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
924 }
925
926 // Advanced robots: max-* go into rank_math_advanced_robots
927 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
928 $has_adv = false;
929 foreach ($adv_keys as $k) {
930 if (array_key_exists($k, $data)) {
931 $has_adv = true;
932 break;
933 }
934 }
935 if ($has_adv) {
936 $existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true);
937 $adv = is_array($existing_adv) ? $existing_adv : [];
938 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
939 $val = (int) $data['max_snippet'];
940 $adv['max-snippet'] = (string) $val;
941 }
942 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
943 $allowed = ['none', 'standard', 'large'];
944 if (in_array($data['max_image_preview'], $allowed, true)) {
945 $adv['max-image-preview'] = (string) $data['max_image_preview'];
946 }
947 }
948 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
949 $val = (int) $data['max_video_preview'];
950 $adv['max-video-preview'] = (string) $val;
951 }
952 if (!empty($adv)) {
953 update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
954 }
955 }
956
957 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
958 if (array_key_exists('noarchive', $data) || array_key_exists('nosnippet', $data) || array_key_exists('noimageindex', $data)) {
959 $existing = get_post_meta($post_id, 'rank_math_robots', true);
960 $robots = is_array($existing) ? $existing : [];
961 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
962 if (!array_key_exists($dir, $data)) {
963 continue;
964 }
965 $robots = array_values(array_diff($robots, [$dir]));
966 if (!empty($data[$dir])) {
967 $robots[] = $dir;
968 }
969 }
970 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
971 }
972
973 // OG
974 if (!empty($data['og_title'])) {
975 update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
976 $wrote = true;
977 }
978 if (!empty($data['og_desc'])) {
979 update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
980 $wrote = true;
981 }
982 if (!empty($data['og_image'])) {
983 update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
984 $img_id = attachment_url_to_postid($data['og_image']);
985 if ($img_id) {
986 update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
987 }
988 $wrote = true;
989 }
990
991 // Twitter
992 if (!empty($data['twitter_title'])) {
993 update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
994 $wrote = true;
995 }
996 if (!empty($data['twitter_desc'])) {
997 update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
998 $wrote = true;
999 }
1000 if (!empty($data['twitter_card'])) {
1001 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
1002 if (in_array($data['twitter_card'], $valid_cards, true)) {
1003 update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
1004 $wrote = true;
1005 }
1006 }
1007
1008 // Canonical, focus keyword, breadcrumb
1009 if (!empty($data['canonical'])) {
1010 update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
1011 $wrote = true;
1012 }
1013 if (!empty($data['focus_keyword'])) {
1014 update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
1015 $wrote = true;
1016 }
1017 if (!empty($data['breadcrumb_title'])) {
1018 update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
1019 $wrote = true;
1020 }
1021
1022 return $wrote;
1023 }
1024
1025 /**
1026 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
1027 *
1028 * @param int $post_id Post ID.
1029 * @param array $data Canonical key/value pairs.
1030 * @return bool True when at least one value-bearing field was written,
1031 * false when the table is missing, the write failed, or the
1032 * payload carried only robots directives. Robots are excluded
1033 * for the reason given on sync_yoast(), so the receipt means
1034 * the same thing for all three plugins.
1035 */
1036 private function sync_aioseo($post_id, array $data) {
1037 global $wpdb;
1038
1039 $table = $wpdb->prefix . 'aioseo_posts';
1040
1041 // Bail if the AIOSEO post table does not exist (plugin not initialised).
1042 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
1043 if ($table_exists !== $table) {
1044 return false;
1045 }
1046
1047 $row = [];
1048 // Robots fill $row too, so a separate flag is needed: the row is still
1049 // written for a robots-only payload, it just is not a content sync.
1050 $wrote = false;
1051
1052 if (!empty($data['title'])) {
1053 $row['title'] = sanitize_text_field($data['title']);
1054 $wrote = true;
1055 }
1056 if (!empty($data['desc'])) {
1057 $row['description'] = sanitize_text_field($data['desc']);
1058 $wrote = true;
1059 }
1060 if (!empty($data['og_title'])) {
1061 $row['og_title'] = sanitize_text_field($data['og_title']);
1062 $wrote = true;
1063 }
1064 if (!empty($data['og_desc'])) {
1065 $row['og_description'] = sanitize_text_field($data['og_desc']);
1066 $wrote = true;
1067 }
1068 if (!empty($data['og_image'])) {
1069 $row['og_image_type'] = 'custom';
1070 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
1071 $wrote = true;
1072 }
1073 if (!empty($data['twitter_title'])) {
1074 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
1075 $wrote = true;
1076 }
1077 if (!empty($data['twitter_desc'])) {
1078 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
1079 $wrote = true;
1080 }
1081 if (!empty($data['twitter_card'])) {
1082 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
1083 if (in_array($data['twitter_card'], $valid_cards, true)) {
1084 $row['twitter_card'] = $data['twitter_card'];
1085 $wrote = true;
1086 }
1087 }
1088 if (!empty($data['canonical'])) {
1089 $row['canonical_url'] = esc_url_raw($data['canonical']);
1090 $wrote = true;
1091 }
1092
1093 // focus keyword as keyphrases JSON
1094 if (!empty($data['focus_keyword'])) {
1095 $row['keyphrases'] = wp_json_encode([
1096 'focus' => [
1097 'keyphrase' => sanitize_text_field($data['focus_keyword']),
1098 'score' => 0,
1099 'analysis' => new \stdClass(),
1100 ],
1101 'additional' => [],
1102 ]);
1103 $wrote = true;
1104 }
1105
1106 // Robots
1107 $has_robots = false;
1108 foreach (['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'] as $k) {
1109 if (array_key_exists($k, $data)) {
1110 $has_robots = true;
1111 break;
1112 }
1113 }
1114 if ($has_robots) {
1115 $row['robots_default'] = 0;
1116 if (array_key_exists('noindex', $data)) {
1117 $row['robots_noindex'] = $data['noindex'] ? 1 : 0;
1118 }
1119 if (array_key_exists('nofollow', $data)) {
1120 $row['robots_nofollow'] = $data['nofollow'] ? 1 : 0;
1121 }
1122 if (array_key_exists('noarchive', $data)) {
1123 $row['robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
1124 }
1125 if (array_key_exists('nosnippet', $data)) {
1126 $row['robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
1127 }
1128 if (array_key_exists('noimageindex', $data)) {
1129 $row['robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
1130 }
1131 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
1132 $row['robots_max_snippet'] = (int) $data['max_snippet'];
1133 }
1134 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
1135 $row['robots_max_videopreview'] = (int) $data['max_video_preview'];
1136 }
1137 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
1138 $allowed = ['none', 'standard', 'large'];
1139 if (in_array($data['max_image_preview'], $allowed, true)) {
1140 $row['robots_max_imagepreview'] = $data['max_image_preview'];
1141 }
1142 }
1143 }
1144
1145 if (empty($row)) {
1146 return false;
1147 }
1148
1149 $row['updated'] = current_time('mysql');
1150
1151 $existing_id = $wpdb->get_var($wpdb->prepare(
1152 "SELECT id FROM {$table} WHERE post_id = %d",
1153 $post_id
1154 ));
1155
1156 if ($existing_id) {
1157 return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote;
1158 }
1159
1160 // New row -- must include all NOT NULL columns with no defaults
1161 $row['post_id'] = $post_id;
1162 $row['created'] = current_time('mysql');
1163 $robot_defaults = [
1164 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
1165 'robots_noindex' => 0,
1166 'robots_nofollow' => 0,
1167 'robots_noarchive' => 0,
1168 'robots_nosnippet' => 0,
1169 'robots_noimageindex' => 0,
1170 'robots_noodp' => 0,
1171 'robots_notranslate' => 0,
1172 ];
1173 $row = array_merge($robot_defaults, $row);
1174
1175 return ($wpdb->insert($table, $row) !== false) && $wrote;
1176 }
1177
1178 // ------------------------------------------------------------------
1179 // Two-way sync: sidebar JSON ↔ legacy meta boxes
1180 // ------------------------------------------------------------------
1181
1182 /**
1183 * Mirror _metasync_robots_advanced JSON → legacy meta box keys.
1184 *
1185 * Called when the sidebar writes the JSON key so the classic-editor
1186 * meta boxes reflect the same values.
1187 *
1188 * @param int $post_id Post ID.
1189 * @param string $json_value Raw JSON string from _metasync_robots_advanced.
1190 */
1191 private function sync_to_legacy_meta($post_id, $json_value) {
1192 static $syncing_legacy = [];
1193 if (!empty($syncing_legacy[$post_id])) {
1194 return;
1195 }
1196 // Our own sync_legacy_to_json() write triggered this. The legacy meta
1197 // box values are the source of truth in that direction, and mirroring
1198 // back would drop any value this function treats as a default (-1 /
1199 // large), so bail out and leave the legacy keys alone.
1200 if (!empty($this->syncing_legacy_to_json[$post_id])) {
1201 return;
1202 }
1203 $syncing_legacy[$post_id] = true;
1204 // Block sync_legacy_to_json from running while we write legacy keys
1205 $this->syncing_json_to_legacy[$post_id] = true;
1206
1207 try {
1208 $robots = is_string($json_value) ? json_decode($json_value, true) : $json_value;
1209 if (!is_array($robots)) {
1210 return;
1211 }
1212
1213 // Build metasync_common_robots array
1214 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1215 if (!is_array($common)) {
1216 $common = [];
1217 }
1218 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1219 if (!empty($robots[$dir])) {
1220 $common[$dir] = $dir;
1221 } else {
1222 unset($common[$dir]);
1223 }
1224 }
1225 if (!empty($common)) {
1226 update_post_meta($post_id, 'metasync_common_robots', $common);
1227 } else {
1228 delete_post_meta($post_id, 'metasync_common_robots');
1229 }
1230
1231 // Build metasync_advance_robots array — skip default values to keep legacy clean
1232 $adv = [];
1233 if (isset($robots['max_snippet']) && $robots['max_snippet'] !== null && (int) $robots['max_snippet'] !== -1) {
1234 $adv['max-snippet'] = ['enable' => '1', 'length' => (string) $robots['max_snippet']];
1235 }
1236 if (isset($robots['max_image_preview']) && $robots['max_image_preview'] !== null && $robots['max_image_preview'] !== 'large') {
1237 $adv['max-image-preview'] = ['enable' => '1', 'length' => (string) $robots['max_image_preview']];
1238 }
1239 if (isset($robots['max_video_preview']) && $robots['max_video_preview'] !== null && (int) $robots['max_video_preview'] !== -1) {
1240 $adv['max-video-preview'] = ['enable' => '1', 'length' => (string) $robots['max_video_preview']];
1241 }
1242 if (!empty($adv)) {
1243 update_post_meta($post_id, 'metasync_advance_robots', $adv);
1244 } else {
1245 delete_post_meta($post_id, 'metasync_advance_robots');
1246 }
1247 } finally {
1248 unset($syncing_legacy[$post_id]);
1249 unset($this->syncing_json_to_legacy[$post_id]);
1250 }
1251 }
1252
1253 /**
1254 * Rebuild _metasync_robots_advanced JSON from legacy meta box keys.
1255 *
1256 * Called when the classic-editor meta boxes save metasync_common_robots
1257 * or metasync_advance_robots so the sidebar JSON stays in sync.
1258 *
1259 * @param int $post_id Post ID.
1260 */
1261 private function sync_legacy_to_json($post_id) {
1262 static $syncing_json = [];
1263 // If sync_to_legacy_meta is currently running, skip — it already wrote the correct JSON
1264 if (!empty($this->syncing_json_to_legacy[$post_id])) {
1265 return;
1266 }
1267 if (!empty($syncing_json[$post_id])) {
1268 return;
1269 }
1270 $syncing_json[$post_id] = true;
1271 // Block sync_to_legacy_meta from reacting to the JSON write below
1272 $this->syncing_legacy_to_json[$post_id] = true;
1273
1274 try {
1275 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1276 if (!is_array($common)) {
1277 $common = [];
1278 }
1279 $adv = get_post_meta($post_id, 'metasync_advance_robots', true);
1280 if (!is_array($adv)) {
1281 $adv = [];
1282 }
1283
1284 $json = [];
1285
1286 // Boolean directives from common_robots
1287 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1288 $json[$dir] = !empty($common[$dir]);
1289 }
1290
1291 // max-* directives from advance_robots
1292 if (!empty($adv['max-snippet']['enable'])) {
1293 $json['max_snippet'] = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1294 }
1295 if (!empty($adv['max-image-preview']['enable'])) {
1296 $json['max_image_preview'] = isset($adv['max-image-preview']['length']) ? (string) $adv['max-image-preview']['length'] : 'large';
1297 }
1298 if (!empty($adv['max-video-preview']['enable'])) {
1299 $json['max_video_preview'] = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1300 }
1301
1302 // Only write if there's something meaningful
1303 $has_value = false;
1304 foreach ($json as $v) {
1305 if ($v !== false && $v !== null) {
1306 $has_value = true;
1307 break;
1308 }
1309 }
1310
1311 if ($has_value) {
1312 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($json));
1313 } else {
1314 delete_post_meta($post_id, '_metasync_robots_advanced');
1315 }
1316 } finally {
1317 unset($syncing_json[$post_id]);
1318 unset($this->syncing_legacy_to_json[$post_id]);
1319 }
1320 }
1321 }
1322