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

1,229 lines 42.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 $sync_instance->sync_post($sync_post_id);
288 }, 0);
289 }
290 return;
291 }
292
293 // Legacy meta box → rebuild _metasync_robots_advanced JSON
294 if ($canonical_key === '_legacy_robots_to_json') {
295 $this->sync_legacy_to_json((int) $post_id);
296 return;
297 }
298
299 // For noindex: convert 'noindex' string to bool
300 if ($canonical_key === 'noindex') {
301 $value = ($meta_value === 'noindex');
302 } else {
303 $value = $meta_value;
304 }
305
306 $this->sync_post(
307 (int) $post_id,
308 [$canonical_key => $value]
309 );
310 }
311
312 /**
313 * Hook handler for deleted_post_meta.
314 *
315 * Only the legacy meta box keys are handled here. Unticking the last
316 * checkbox in the Common Robots meta box deletes metasync_common_robots
317 * instead of updating it, so without a delete hook the mirrored
318 * _metasync_robots_advanced JSON kept the stale directive — and because the
319 * JSON is the highest-priority source in the output resolver, the page went
320 * on emitting a directive the editor had just cleared.
321 *
322 * Deletes of the JSON key itself are deliberately NOT routed into
323 * on_meta_updated: that path mirrors the value back onto the legacy meta
324 * boxes, so an empty value would wipe them.
325 *
326 * @param array $meta_ids Meta row IDs (unused).
327 * @param int $post_id Post ID.
328 * @param string $meta_key Meta key being deleted.
329 */
330 public function on_meta_deleted($meta_ids, $post_id, $meta_key) {
331 if ($meta_key !== 'metasync_common_robots' && $meta_key !== 'metasync_advance_robots') {
332 return;
333 }
334
335 $this->sync_legacy_to_json((int) $post_id);
336 }
337
338 // ------------------------------------------------------------------
339 // Data collection
340 // ------------------------------------------------------------------
341
342 /**
343 * Collect canonical SEO data from all MetaSync post meta.
344 *
345 * Reads all meta in one get_post_custom() call for performance.
346 * When $fields is non-empty, the result is filtered to only those keys.
347 *
348 * @param int $post_id Post ID.
349 * @param array $fields Optional pre-resolved canonical key/value pairs.
350 * @return array Canonical data array.
351 */
352 private function collect_post_data($post_id, array $fields = []) {
353 // If caller already resolved specific fields, return them directly.
354 // Canonical still gets validated — this branch serves the
355 // updated_post_meta fast-path, which would otherwise mirror a raw
356 // (possibly corrupted) value into third-party storage.
357 if (!empty($fields)) {
358 if (array_key_exists('canonical', $fields)) {
359 $fields['canonical'] = Metasync_Canonical_Sanitizer::sanitize($fields['canonical']);
360 if ($fields['canonical'] === '') {
361 unset($fields['canonical']);
362 }
363 }
364
365 // Same reasoning for the social title/description fields: on this
366 // fast-path the value arrives straight from the meta write, so a stored
367 // "Auto Draft" pre-fill placeholder would be mirrored verbatim into
368 // Yoast/RankMath/AIOSEO and emitted there as og:title. Drop it instead so
369 // each plugin keeps whatever it already has.
370 // @phpstan-ignore-next-line function.alreadyNarrowedType
371 if (method_exists('Metasync_OpenGraph', 'is_auto_draft_title')) {
372 foreach (['og_title', 'og_desc', 'twitter_title', 'twitter_desc'] as $social_field) {
373 if (array_key_exists($social_field, $fields)
374 && Metasync_OpenGraph::is_auto_draft_title($fields[$social_field])
375 ) {
376 unset($fields[$social_field]);
377 }
378 }
379 }
380
381 return $this->apply_feature_flags_to_payload($fields);
382 }
383
384 $all_meta = get_post_custom($post_id);
385
386 $get = function ($key) use ($all_meta) {
387 if (!isset($all_meta[$key])) {
388 return '';
389 }
390 $value = is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
391
392 // The meta box pre-fills the social title/description fields from the post
393 // title, which is the "Auto Draft" placeholder on a brand-new post. Collapse
394 // it to '' here so the placeholder is never mirrored into Yoast/RankMath/
395 // AIOSEO storage, where those plugins would emit it as og:title.
396 // Same sanitize-at-the-source placement as Metasync_Canonical_Sanitizer below.
397 //
398 // method_exists (not just class_exists) for the same reason
399 // sync_layer_handles() checks: this runs on meta writes during front-end
400 // requests, and a partially updated install can leave an older
401 // class-metasync-opengraph.php beside this file.
402 // @phpstan-ignore-next-line function.alreadyNarrowedType
403 if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')
404 && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS')
405 && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
406 ) {
407 return Metasync_OpenGraph::strip_auto_draft_title($value);
408 }
409
410 return $value;
411 };
412
413 $data = [];
414
415 // Helper: first non-empty value from a list of meta keys.
416 //
417 // Internal OTTO keys are skipped while their OTTO Persistence setting is
418 // disabled. Gating here and not only in on_meta_updated() is what makes
419 // the setting hold for the callers that re-read meta themselves rather
420 // than passing a resolved field: the sync at the end of OTTO SSR
421 // processing (metasync_update_comprehensive_seo_fields), the MCP OTTO
422 // refresh tool, and the robots full-sync branch below.
423 //
424 // Each chain lists the manually edited key first and the internal OTTO
425 // key last, so a customer-entered value is unaffected and still wins.
426 $first = function (...$keys) use ($get) {
427 foreach ($keys as $key) {
428 if (!$this->otto_persistence_allows($key)) {
429 continue;
430 }
431 $val = $get($key);
432 if (!empty($val)) {
433 return $val;
434 }
435 }
436 return '';
437 };
438
439 // Order comes from Metasync_Seo_Precedence, the one place it is defined.
440 //
441 // Imported values come last so a sync triggered by something else still
442 // reports a title rather than an empty one. They are deliberately NOT in
443 // WATCHED_KEYS or the on_meta_updated map above: a sync mirrors the
444 // canonical value into an active third-party plugin's own storage, and
445 // that plugin renders at a higher effective precedence than OTTO — so
446 // syncing on an imported write would put the imported title back in
447 // front of OTTO, which is the exact behaviour this key exists to avoid.
448 $data['title'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_TITLE));
449
450 $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION));
451
452 // Robots directives: check _metasync_robots_advanced JSON first,
453 // then fall back to metasync_common_robots array + metasync_advance_robots array
454 $robots_json_raw = $get('_metasync_robots_advanced');
455 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
456
457 if (is_array($robots_json)) {
458 $data['noindex'] = $this->resolve_synced_noindex(
459 $robots_json,
460 $get('_metasync_robots_index'),
461 $get('metasync_common_robots')
462 );
463 $data['nofollow'] = !empty($robots_json['nofollow']);
464 $data['noarchive'] = !empty($robots_json['noarchive']);
465 $data['nosnippet'] = !empty($robots_json['nosnippet']);
466 $data['noimageindex'] = !empty($robots_json['noimageindex']);
467 $data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null);
468 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
469 $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);
470 } else {
471 // noindex: dedicated key, falling back to the Common Robots checkbox
472 // array — same sources the front-end emitter honours.
473 $data['noindex'] = $this->resolve_synced_noindex(
474 null,
475 $get('_metasync_robots_index'),
476 $get('metasync_common_robots')
477 );
478
479 // Common robots array (serialized)
480 $common_raw = $get('metasync_common_robots');
481 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
482 if (!is_array($common_robots)) {
483 $common_robots = [];
484 }
485
486 $data['nofollow'] = !empty($common_robots['nofollow']);
487 $data['noarchive'] = !empty($common_robots['noarchive']);
488 $data['nosnippet'] = !empty($common_robots['nosnippet']);
489 $data['noimageindex'] = !empty($common_robots['noimageindex']);
490
491 // Advance robots array (serialized)
492 $adv_raw = $get('metasync_advance_robots');
493 $adv_robots = !empty($adv_raw) ? maybe_unserialize($adv_raw) : [];
494 if (!is_array($adv_robots)) {
495 $adv_robots = [];
496 }
497
498 // Each advance-robots directive is stored as ['enable' => .., 'length' => ..].
499 // Read the length scalar (honouring the enable flag) instead of casting the
500 // whole sub-array — (int) of a non-empty array is 1, which is what produced the
501 // "max-snippet:1" instead of "-1" and dropped the image-preview value. Mirrors
502 // sync_legacy_to_json() so both directions read the legacy format identically.
503 $data['max_snippet'] = !empty($adv_robots['max-snippet']['enable'])
504 ? (isset($adv_robots['max-snippet']['length']) ? (int) $adv_robots['max-snippet']['length'] : -1)
505 : null;
506 $data['max_image_preview'] = !empty($adv_robots['max-image-preview']['enable'])
507 ? (isset($adv_robots['max-image-preview']['length']) ? (string) $adv_robots['max-image-preview']['length'] : 'large')
508 : null;
509 $data['max_video_preview'] = !empty($adv_robots['max-video-preview']['enable'])
510 ? (isset($adv_robots['max-video-preview']['length']) ? (int) $adv_robots['max-video-preview']['length'] : -1)
511 : null;
512 }
513
514 // Social / OG: persisted > volatile OTTO
515 $data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title');
516 $data['og_desc'] = $first('_metasync_og_description', '_metasync_otto_og_description');
517 $data['og_image'] = $get('_metasync_og_image');
518
519 // Twitter: persisted > volatile OTTO
520 $data['twitter_title'] = $first('_metasync_twitter_title', '_metasync_otto_twitter_title');
521 $data['twitter_desc'] = $first('_metasync_twitter_description', '_metasync_otto_twitter_description');
522 $data['twitter_card'] = $get('_metasync_twitter_card');
523
524 // Canonical, focus keyword, breadcrumb
525 // Canonical is validated at the source so a corrupted value ("Array")
526 // never propagates into Yoast/RankMath/AIOSEO storage.
527 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
528 $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
529 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
530
531 return $this->apply_feature_flags_to_payload($data);
532 }
533
534 /**
535 * Drop payload keys whose owning feature is switched off in Editor Settings.
536 *
537 * A disabled meta box means MetaSync must not interfere with that feature
538 * anywhere: it emits nothing, suppresses nothing, and — the part this
539 * guard covers — does not mirror its values into Yoast / Rank Math /
540 * AIOSEO storage. Without the drop, a sync fired while the feature is off
541 * resolves the MetaSync value with the feature already stripped and then
542 * writes that result over the third-party plugin's own value (Rank Math's
543 * stored noindex replaced by a plain 'index'). The writers are all
544 * array_key_exists-guarded, so removing a key here skips every write of it.
545 *
546 * @param array $data Canonical payload keyed by canonical field name.
547 * @return array
548 */
549 private function apply_feature_flags_to_payload(array $data) {
550 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) {
551 unset(
552 $data['noindex'],
553 $data['nofollow'],
554 $data['noarchive'],
555 $data['nosnippet'],
556 $data['noimageindex']
557 );
558 }
559 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::ADVANCE_ROBOTS)) {
560 unset(
561 $data['max_snippet'],
562 $data['max_image_preview'],
563 $data['max_video_preview']
564 );
565 }
566 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
567 unset(
568 $data['og_title'],
569 $data['og_desc'],
570 $data['og_image'],
571 $data['twitter_title'],
572 $data['twitter_desc'],
573 $data['twitter_card']
574 );
575 }
576 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
577 unset($data['canonical']);
578 }
579
580 return $data;
581 }
582
583 /**
584 * Resolve the noindex directive for syncing the way the front-end emitter
585 * does: any of the three sources saying noindex wins, and none can veto
586 * another. The JSON rebuilt by sync_legacy_to_json() deliberately omits
587 * noindex, so reading the JSON alone reported a checkbox-only noindex as
588 * false — and the writers translated that into an explicit 'index' that
589 * overwrote a noindex the third-party plugin already held.
590 *
591 * @param mixed $robots_json Decoded _metasync_robots_advanced payload (or null).
592 * @param mixed $robots_index Raw _metasync_robots_index meta value.
593 * @param mixed $common_raw Raw metasync_common_robots meta value.
594 * @return bool
595 */
596 private function resolve_synced_noindex($robots_json, $robots_index, $common_raw) {
597 if (is_array($robots_json) && !empty($robots_json['noindex'])) {
598 return true;
599 }
600 if ($robots_index === 'noindex') {
601 return true;
602 }
603 if (!empty($common_raw)) {
604 $common = maybe_unserialize($common_raw);
605 if (is_array($common) && !empty($common['noindex'])) {
606 return true;
607 }
608 }
609
610 return false;
611 }
612
613 // ------------------------------------------------------------------
614 // Plugin detectors
615 // ------------------------------------------------------------------
616
617 /**
618 * Check whether Yoast SEO (free or premium) is active.
619 *
620 * @return bool
621 */
622 private function is_yoast_active() {
623 $this->ensure_plugin_api();
624 return is_plugin_active('wordpress-seo/wp-seo.php')
625 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
626 }
627
628 /**
629 * Check whether Rank Math SEO is active.
630 *
631 * @return bool
632 */
633 private function is_rankmath_active() {
634 $this->ensure_plugin_api();
635 return is_plugin_active('seo-by-rank-math/rank-math.php')
636 || is_plugin_active('seo-by-rankmath/rank-math.php');
637 }
638
639 /**
640 * Check whether AIOSEO (free or pro) is active.
641 *
642 * @return bool
643 */
644 private function is_aioseo_active() {
645 $this->ensure_plugin_api();
646 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
647 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
648 }
649
650 /**
651 * Ensure is_plugin_active() is loaded on the frontend.
652 */
653 private function ensure_plugin_api() {
654 if (!function_exists('is_plugin_active')) {
655 require_once ABSPATH . 'wp-admin/includes/plugin.php';
656 }
657 }
658
659 // ------------------------------------------------------------------
660 // Per-plugin sync
661 // ------------------------------------------------------------------
662
663 /**
664 * Mirror canonical data into Yoast post meta and indexable cache.
665 *
666 * @param int $post_id Post ID.
667 * @param array $data Canonical key/value pairs.
668 * @return bool True once dispatch completes.
669 */
670 private function sync_yoast($post_id, array $data) {
671 // title
672 if (!empty($data['title'])) {
673 update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
674 }
675
676 // description -- strip newlines first
677 if (!empty($data['desc'])) {
678 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
679 update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
680 }
681
682 // noindex: '0'=default, '1'=noindex, '2'=index
683 if (array_key_exists('noindex', $data)) {
684 $val = $data['noindex'] ? '1' : '2';
685 update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
686 }
687
688 // nofollow: '0'=follow, '1'=nofollow
689 if (array_key_exists('nofollow', $data)) {
690 update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
691 }
692
693 // advanced robots: comma-separated NO spaces
694 $adv = [];
695 if (!empty($data['noarchive'])) {
696 $adv[] = 'noarchive';
697 }
698 if (!empty($data['nosnippet'])) {
699 $adv[] = 'nosnippet';
700 }
701 if (!empty($data['noimageindex'])) {
702 $adv[] = 'noimageindex';
703 }
704 update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
705
706 // OG
707 if (!empty($data['og_title'])) {
708 update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
709 }
710 if (!empty($data['og_desc'])) {
711 update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
712 }
713 if (!empty($data['og_image'])) {
714 update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
715 }
716
717 // Twitter
718 if (!empty($data['twitter_title'])) {
719 update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
720 }
721 if (!empty($data['twitter_desc'])) {
722 update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
723 }
724
725 // Canonical, focus keyword, breadcrumb
726 if (!empty($data['canonical'])) {
727 update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
728 }
729 if (!empty($data['focus_keyword'])) {
730 update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
731 }
732 if (!empty($data['breadcrumb_title'])) {
733 update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
734 }
735
736 // Update wp_yoast_indexable cache row for immediate effect
737 global $wpdb;
738 $indexable_table = $wpdb->prefix . 'yoast_indexable';
739
740 $updates = [];
741 if (!empty($data['title'])) {
742 $updates['title'] = mb_substr($data['title'], 0, 191);
743 }
744 if (!empty($data['desc'])) {
745 $updates['description'] = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
746 }
747 if (array_key_exists('noindex', $data)) {
748 $updates['is_robots_noindex'] = $data['noindex'] ? 1 : 0;
749 }
750 if (array_key_exists('nofollow', $data)) {
751 $updates['is_robots_nofollow'] = $data['nofollow'] ? 1 : 0;
752 }
753 if (array_key_exists('noarchive', $data)) {
754 $updates['is_robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
755 }
756 if (array_key_exists('nosnippet', $data)) {
757 $updates['is_robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
758 }
759 if (array_key_exists('noimageindex', $data)) {
760 $updates['is_robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
761 }
762 if (!empty($data['og_title'])) {
763 $updates['open_graph_title'] = mb_substr($data['og_title'], 0, 191);
764 }
765 if (!empty($data['og_image'])) {
766 $updates['open_graph_image'] = $data['og_image'];
767 }
768 if (!empty($data['twitter_title'])) {
769 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
770 }
771 if (!empty($data['twitter_card'])) {
772 // twitter_card column only exists in newer Yoast versions; skip if absent
773 $col_check = $wpdb->get_var($wpdb->prepare(
774 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
775 DB_NAME,
776 $indexable_table
777 ));
778 if ($col_check) {
779 $updates['twitter_card'] = $data['twitter_card'];
780 }
781 }
782 if (!empty($data['canonical'])) {
783 $updates['canonical'] = $data['canonical'];
784 }
785 if (!empty($data['focus_keyword'])) {
786 $updates['primary_focus_keyword'] = mb_substr($data['focus_keyword'], 0, 191);
787 }
788 if (!empty($data['breadcrumb_title'])) {
789 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
790 }
791
792 if (!empty($updates)) {
793 $row_exists = $wpdb->get_var($wpdb->prepare(
794 "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
795 $post_id
796 ));
797
798 if ($row_exists) {
799 $wpdb->update(
800 $indexable_table,
801 $updates,
802 ['object_id' => $post_id, 'object_type' => 'post']
803 );
804 } else {
805 $post = get_post($post_id);
806 $insert = array_merge([
807 'object_id' => $post_id,
808 'object_type' => 'post',
809 'object_sub_type' => $post ? $post->post_type : 'post',
810 'post_status' => $post ? $post->post_status : 'publish',
811 'author_id' => $post ? (int) $post->post_author : 0,
812 'is_robots_noindex' => 0,
813 'is_robots_nofollow' => 0,
814 'is_robots_noarchive' => 0,
815 'is_robots_nosnippet' => 0,
816 'is_robots_noimageindex' => 0,
817 'is_cornerstone' => 0,
818 'created_at' => current_time('mysql'),
819 'updated_at' => current_time('mysql'),
820 ], $updates);
821 $wpdb->insert($indexable_table, $insert);
822 }
823 }
824
825 return true;
826 }
827
828 /**
829 * Mirror canonical data into Rank Math post meta.
830 *
831 * @param int $post_id Post ID.
832 * @param array $data Canonical key/value pairs.
833 * @return bool True once dispatch completes.
834 */
835 private function sync_rankmath($post_id, array $data) {
836 // title, desc
837 if (!empty($data['title'])) {
838 update_post_meta($post_id, 'rank_math_title', $data['title']);
839 }
840 if (!empty($data['desc'])) {
841 update_post_meta($post_id, 'rank_math_description', $data['desc']);
842 }
843
844 // robots: PHP indexed array
845 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
846 $existing = get_post_meta($post_id, 'rank_math_robots', true);
847 $robots = is_array($existing) ? $existing : [];
848 $robots = array_values(array_diff($robots, ['index', 'noindex', 'follow', 'nofollow']));
849 if (array_key_exists('noindex', $data)) {
850 $robots[] = $data['noindex'] ? 'noindex' : 'index';
851 }
852 if (array_key_exists('nofollow', $data)) {
853 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
854 }
855 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
856 }
857
858 // Advanced robots: max-* go into rank_math_advanced_robots
859 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
860 $has_adv = false;
861 foreach ($adv_keys as $k) {
862 if (array_key_exists($k, $data)) {
863 $has_adv = true;
864 break;
865 }
866 }
867 if ($has_adv) {
868 $existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true);
869 $adv = is_array($existing_adv) ? $existing_adv : [];
870 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
871 $val = (int) $data['max_snippet'];
872 $adv['max-snippet'] = (string) $val;
873 }
874 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
875 $allowed = ['none', 'standard', 'large'];
876 if (in_array($data['max_image_preview'], $allowed, true)) {
877 $adv['max-image-preview'] = (string) $data['max_image_preview'];
878 }
879 }
880 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
881 $val = (int) $data['max_video_preview'];
882 $adv['max-video-preview'] = (string) $val;
883 }
884 if (!empty($adv)) {
885 update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
886 }
887 }
888
889 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
890 if (array_key_exists('noarchive', $data) || array_key_exists('nosnippet', $data) || array_key_exists('noimageindex', $data)) {
891 $existing = get_post_meta($post_id, 'rank_math_robots', true);
892 $robots = is_array($existing) ? $existing : [];
893 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
894 if (!array_key_exists($dir, $data)) {
895 continue;
896 }
897 $robots = array_values(array_diff($robots, [$dir]));
898 if (!empty($data[$dir])) {
899 $robots[] = $dir;
900 }
901 }
902 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
903 }
904
905 // OG
906 if (!empty($data['og_title'])) {
907 update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
908 }
909 if (!empty($data['og_desc'])) {
910 update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
911 }
912 if (!empty($data['og_image'])) {
913 update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
914 $img_id = attachment_url_to_postid($data['og_image']);
915 if ($img_id) {
916 update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
917 }
918 }
919
920 // Twitter
921 if (!empty($data['twitter_title'])) {
922 update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
923 }
924 if (!empty($data['twitter_desc'])) {
925 update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
926 }
927 if (!empty($data['twitter_card'])) {
928 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
929 if (in_array($data['twitter_card'], $valid_cards, true)) {
930 update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
931 }
932 }
933
934 // Canonical, focus keyword, breadcrumb
935 if (!empty($data['canonical'])) {
936 update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
937 }
938 if (!empty($data['focus_keyword'])) {
939 update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
940 }
941 if (!empty($data['breadcrumb_title'])) {
942 update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
943 }
944
945 return true;
946 }
947
948 /**
949 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
950 *
951 * @param int $post_id Post ID.
952 * @param array $data Canonical key/value pairs.
953 * @return bool True when the row was written, false when the table is
954 * missing or the write failed.
955 */
956 private function sync_aioseo($post_id, array $data) {
957 global $wpdb;
958
959 $table = $wpdb->prefix . 'aioseo_posts';
960
961 // Bail if the AIOSEO post table does not exist (plugin not initialised).
962 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
963 if ($table_exists !== $table) {
964 return false;
965 }
966
967 $row = [];
968
969 if (!empty($data['title'])) {
970 $row['title'] = sanitize_text_field($data['title']);
971 }
972 if (!empty($data['desc'])) {
973 $row['description'] = sanitize_text_field($data['desc']);
974 }
975 if (!empty($data['og_title'])) {
976 $row['og_title'] = sanitize_text_field($data['og_title']);
977 }
978 if (!empty($data['og_desc'])) {
979 $row['og_description'] = sanitize_text_field($data['og_desc']);
980 }
981 if (!empty($data['og_image'])) {
982 $row['og_image_type'] = 'custom';
983 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
984 }
985 if (!empty($data['twitter_title'])) {
986 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
987 }
988 if (!empty($data['twitter_desc'])) {
989 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
990 }
991 if (!empty($data['twitter_card'])) {
992 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
993 if (in_array($data['twitter_card'], $valid_cards, true)) {
994 $row['twitter_card'] = $data['twitter_card'];
995 }
996 }
997 if (!empty($data['canonical'])) {
998 $row['canonical_url'] = esc_url_raw($data['canonical']);
999 }
1000
1001 // focus keyword as keyphrases JSON
1002 if (!empty($data['focus_keyword'])) {
1003 $row['keyphrases'] = wp_json_encode([
1004 'focus' => [
1005 'keyphrase' => sanitize_text_field($data['focus_keyword']),
1006 'score' => 0,
1007 'analysis' => new \stdClass(),
1008 ],
1009 'additional' => [],
1010 ]);
1011 }
1012
1013 // Robots
1014 $has_robots = false;
1015 foreach (['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'] as $k) {
1016 if (array_key_exists($k, $data)) {
1017 $has_robots = true;
1018 break;
1019 }
1020 }
1021 if ($has_robots) {
1022 $row['robots_default'] = 0;
1023 if (array_key_exists('noindex', $data)) {
1024 $row['robots_noindex'] = $data['noindex'] ? 1 : 0;
1025 }
1026 if (array_key_exists('nofollow', $data)) {
1027 $row['robots_nofollow'] = $data['nofollow'] ? 1 : 0;
1028 }
1029 if (array_key_exists('noarchive', $data)) {
1030 $row['robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
1031 }
1032 if (array_key_exists('nosnippet', $data)) {
1033 $row['robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
1034 }
1035 if (array_key_exists('noimageindex', $data)) {
1036 $row['robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
1037 }
1038 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
1039 $row['robots_max_snippet'] = (int) $data['max_snippet'];
1040 }
1041 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
1042 $row['robots_max_videopreview'] = (int) $data['max_video_preview'];
1043 }
1044 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
1045 $allowed = ['none', 'standard', 'large'];
1046 if (in_array($data['max_image_preview'], $allowed, true)) {
1047 $row['robots_max_imagepreview'] = $data['max_image_preview'];
1048 }
1049 }
1050 }
1051
1052 if (empty($row)) {
1053 return false;
1054 }
1055
1056 $row['updated'] = current_time('mysql');
1057
1058 $existing_id = $wpdb->get_var($wpdb->prepare(
1059 "SELECT id FROM {$table} WHERE post_id = %d",
1060 $post_id
1061 ));
1062
1063 if ($existing_id) {
1064 return $wpdb->update($table, $row, ['post_id' => $post_id]) !== false;
1065 }
1066
1067 // New row -- must include all NOT NULL columns with no defaults
1068 $row['post_id'] = $post_id;
1069 $row['created'] = current_time('mysql');
1070 $robot_defaults = [
1071 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
1072 'robots_noindex' => 0,
1073 'robots_nofollow' => 0,
1074 'robots_noarchive' => 0,
1075 'robots_nosnippet' => 0,
1076 'robots_noimageindex' => 0,
1077 'robots_noodp' => 0,
1078 'robots_notranslate' => 0,
1079 ];
1080 $row = array_merge($robot_defaults, $row);
1081
1082 return $wpdb->insert($table, $row) !== false;
1083 }
1084
1085 // ------------------------------------------------------------------
1086 // Two-way sync: sidebar JSON ↔ legacy meta boxes
1087 // ------------------------------------------------------------------
1088
1089 /**
1090 * Mirror _metasync_robots_advanced JSON → legacy meta box keys.
1091 *
1092 * Called when the sidebar writes the JSON key so the classic-editor
1093 * meta boxes reflect the same values.
1094 *
1095 * @param int $post_id Post ID.
1096 * @param string $json_value Raw JSON string from _metasync_robots_advanced.
1097 */
1098 private function sync_to_legacy_meta($post_id, $json_value) {
1099 static $syncing_legacy = [];
1100 if (!empty($syncing_legacy[$post_id])) {
1101 return;
1102 }
1103 // Our own sync_legacy_to_json() write triggered this. The legacy meta
1104 // box values are the source of truth in that direction, and mirroring
1105 // back would drop any value this function treats as a default (-1 /
1106 // large), so bail out and leave the legacy keys alone.
1107 if (!empty($this->syncing_legacy_to_json[$post_id])) {
1108 return;
1109 }
1110 $syncing_legacy[$post_id] = true;
1111 // Block sync_legacy_to_json from running while we write legacy keys
1112 $this->syncing_json_to_legacy[$post_id] = true;
1113
1114 try {
1115 $robots = is_string($json_value) ? json_decode($json_value, true) : $json_value;
1116 if (!is_array($robots)) {
1117 return;
1118 }
1119
1120 // Build metasync_common_robots array
1121 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1122 if (!is_array($common)) {
1123 $common = [];
1124 }
1125 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1126 if (!empty($robots[$dir])) {
1127 $common[$dir] = $dir;
1128 } else {
1129 unset($common[$dir]);
1130 }
1131 }
1132 if (!empty($common)) {
1133 update_post_meta($post_id, 'metasync_common_robots', $common);
1134 } else {
1135 delete_post_meta($post_id, 'metasync_common_robots');
1136 }
1137
1138 // Build metasync_advance_robots array — skip default values to keep legacy clean
1139 $adv = [];
1140 if (isset($robots['max_snippet']) && $robots['max_snippet'] !== null && (int) $robots['max_snippet'] !== -1) {
1141 $adv['max-snippet'] = ['enable' => '1', 'length' => (string) $robots['max_snippet']];
1142 }
1143 if (isset($robots['max_image_preview']) && $robots['max_image_preview'] !== null && $robots['max_image_preview'] !== 'large') {
1144 $adv['max-image-preview'] = ['enable' => '1', 'length' => (string) $robots['max_image_preview']];
1145 }
1146 if (isset($robots['max_video_preview']) && $robots['max_video_preview'] !== null && (int) $robots['max_video_preview'] !== -1) {
1147 $adv['max-video-preview'] = ['enable' => '1', 'length' => (string) $robots['max_video_preview']];
1148 }
1149 if (!empty($adv)) {
1150 update_post_meta($post_id, 'metasync_advance_robots', $adv);
1151 } else {
1152 delete_post_meta($post_id, 'metasync_advance_robots');
1153 }
1154 } finally {
1155 unset($syncing_legacy[$post_id]);
1156 unset($this->syncing_json_to_legacy[$post_id]);
1157 }
1158 }
1159
1160 /**
1161 * Rebuild _metasync_robots_advanced JSON from legacy meta box keys.
1162 *
1163 * Called when the classic-editor meta boxes save metasync_common_robots
1164 * or metasync_advance_robots so the sidebar JSON stays in sync.
1165 *
1166 * @param int $post_id Post ID.
1167 */
1168 private function sync_legacy_to_json($post_id) {
1169 static $syncing_json = [];
1170 // If sync_to_legacy_meta is currently running, skip — it already wrote the correct JSON
1171 if (!empty($this->syncing_json_to_legacy[$post_id])) {
1172 return;
1173 }
1174 if (!empty($syncing_json[$post_id])) {
1175 return;
1176 }
1177 $syncing_json[$post_id] = true;
1178 // Block sync_to_legacy_meta from reacting to the JSON write below
1179 $this->syncing_legacy_to_json[$post_id] = true;
1180
1181 try {
1182 $common = get_post_meta($post_id, 'metasync_common_robots', true);
1183 if (!is_array($common)) {
1184 $common = [];
1185 }
1186 $adv = get_post_meta($post_id, 'metasync_advance_robots', true);
1187 if (!is_array($adv)) {
1188 $adv = [];
1189 }
1190
1191 $json = [];
1192
1193 // Boolean directives from common_robots
1194 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1195 $json[$dir] = !empty($common[$dir]);
1196 }
1197
1198 // max-* directives from advance_robots
1199 if (!empty($adv['max-snippet']['enable'])) {
1200 $json['max_snippet'] = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1201 }
1202 if (!empty($adv['max-image-preview']['enable'])) {
1203 $json['max_image_preview'] = isset($adv['max-image-preview']['length']) ? (string) $adv['max-image-preview']['length'] : 'large';
1204 }
1205 if (!empty($adv['max-video-preview']['enable'])) {
1206 $json['max_video_preview'] = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1207 }
1208
1209 // Only write if there's something meaningful
1210 $has_value = false;
1211 foreach ($json as $v) {
1212 if ($v !== false && $v !== null) {
1213 $has_value = true;
1214 break;
1215 }
1216 }
1217
1218 if ($has_value) {
1219 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($json));
1220 } else {
1221 delete_post_meta($post_id, '_metasync_robots_advanced');
1222 }
1223 } finally {
1224 unset($syncing_json[$post_id]);
1225 unset($this->syncing_legacy_to_json[$post_id]);
1226 }
1227 }
1228 }
1229