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

991 lines 33.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Post-Level SEO Plugin Sync
4 *
5 * Mirrors MetaSync post meta (`_metasync_*`) into the active third-party
6 * SEO plugins' post storage (Yoast, Rank Math, AIOSEO) so that posts and
7 * pages render MetaSync-managed values regardless of which plugin is
8 * actually rendering the frontend.
9 *
10 * @package MetaSync
11 * @subpackage MetaSync/includes
12 * @since 2.8.25
13 */
14
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 class Metasync_Plugin_Sync {
20
21 /**
22 * Singleton instance.
23 *
24 * @var self|null
25 */
26 private static $instance = null;
27
28 /**
29 * Guard: tracks post IDs currently being synced from JSON → legacy.
30 * Prevents sync_legacy_to_json from overwriting the correct JSON value.
31 *
32 * @var array
33 */
34 private $syncing_json_to_legacy = [];
35
36 /**
37 * Guard: tracks post IDs currently being synced from legacy → JSON.
38 *
39 * Prevents sync_to_legacy_meta from firing in response to the JSON write
40 * that sync_legacy_to_json just made. Without this the round trip is
41 * lossy: the JSON format cannot distinguish "user explicitly chose the
42 * default" from "nothing set" (the sidebar always emits max_snippet=-1
43 * and max_image_preview=large), so the mirror back would discard the
44 * legacy meta box values it had just derived the JSON from.
45 *
46 * @var array
47 */
48 private $syncing_legacy_to_json = [];
49
50 /**
51 * MetaSync meta keys that trigger a sync when written.
52 *
53 * @var array
54 */
55 const WATCHED_KEYS = [
56 // Sidebar / persisted keys
57 '_metasync_seo_title',
58 '_metasync_seo_desc',
59 '_metasync_metatitle',
60 '_metasync_metadesc',
61 '_metasync_robots_index',
62 '_metasync_robots_advanced',
63 '_metasync_og_title',
64 '_metasync_og_description',
65 '_metasync_og_image',
66 '_metasync_twitter_title',
67 '_metasync_twitter_description',
68 '_metasync_twitter_card',
69 '_metasync_canonical_url',
70 '_metasync_focus_keyword',
71 '_metasync_breadcrumb_title',
72 // OTTO volatile keys
73 '_metasync_otto_title',
74 '_metasync_otto_description',
75 '_metasync_otto_og_title',
76 '_metasync_otto_og_description',
77 '_metasync_otto_twitter_title',
78 '_metasync_otto_twitter_description',
79 '_metasync_otto_keywords',
80 ];
81
82 /**
83 * Get singleton instance.
84 *
85 * @return self
86 */
87 public static function get_instance() {
88 if (self::$instance === null) {
89 self::$instance = new self();
90 }
91 return self::$instance;
92 }
93
94 /**
95 * Sync MetaSync post meta to every active SEO plugin.
96 *
97 * When $fields is non-empty only those canonical keys are synced.
98 * When empty a full sync of all canonical keys is performed.
99 *
100 * @param int $post_id Post ID.
101 * @param array $fields Optional subset of canonical key/value pairs to sync.
102 * @return array Results keyed by plugin: ['yoast'=>bool,'rankmath'=>bool,'aioseo'=>bool].
103 */
104 public function sync_post($post_id, array $fields = []) {
105 static $syncing = [];
106
107 if (!empty($syncing[$post_id])) {
108 return [];
109 }
110 $syncing[$post_id] = true;
111
112 try {
113 $results = [];
114
115 if ($post_id <= 0) {
116 return $results;
117 }
118
119 $data = $this->collect_post_data($post_id, $fields);
120
121 if (empty($data)) {
122 return $results;
123 }
124
125 if ($this->is_yoast_active()) {
126 $results['yoast'] = $this->sync_yoast((int) $post_id, $data);
127 }
128
129 if ($this->is_rankmath_active()) {
130 $results['rankmath'] = $this->sync_rankmath((int) $post_id, $data);
131 }
132
133 if ($this->is_aioseo_active()) {
134 $results['aioseo'] = $this->sync_aioseo((int) $post_id, $data);
135 }
136
137 // Write sync timestamp
138 $ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
139 $sync_data = !empty($ts) ? json_decode($ts, true) : [];
140 if (!is_array($sync_data)) {
141 $sync_data = [];
142 }
143 if ($results['yoast'] ?? false) {
144 $sync_data['yoast'] = gmdate('c');
145 }
146 if ($results['rankmath'] ?? false) {
147 $sync_data['rankmath'] = gmdate('c');
148 }
149 if ($results['aioseo'] ?? false) {
150 $sync_data['aioseo'] = gmdate('c');
151 }
152 if (!empty($sync_data)) {
153 update_post_meta($post_id, '_metasync_plugin_sync_ts', wp_json_encode($sync_data));
154 }
155
156 return $results;
157 } finally {
158 unset($syncing[$post_id]);
159 }
160 }
161
162 /**
163 * Hook handler for updated_post_meta / added_post_meta.
164 *
165 * Fires sync_post when a watched MetaSync meta key is written.
166 *
167 * @param int $meta_id Meta row ID (unused).
168 * @param int $post_id Post ID.
169 * @param string $meta_key Meta key being written.
170 * @param mixed $meta_value Meta value being written.
171 */
172 public function on_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
173 $watched = [
174 // Sidebar / persisted keys
175 '_metasync_seo_title' => 'title',
176 '_metasync_seo_desc' => 'desc',
177 '_metasync_metatitle' => 'title',
178 '_metasync_metadesc' => 'desc',
179 '_metasync_robots_index' => 'noindex',
180 '_metasync_og_title' => 'og_title',
181 '_metasync_og_description' => 'og_desc',
182 '_metasync_og_image' => 'og_image',
183 '_metasync_twitter_title' => 'twitter_title',
184 '_metasync_twitter_description' => 'twitter_desc',
185 '_metasync_twitter_card' => 'twitter_card',
186 '_metasync_canonical_url' => 'canonical',
187 '_metasync_focus_keyword' => 'focus_keyword',
188 '_metasync_breadcrumb_title' => 'breadcrumb_title',
189 '_metasync_robots_advanced' => '_robots_advanced_json',
190 // OTTO volatile keys (SSR writes these even without persistence)
191 '_metasync_otto_title' => 'title',
192 '_metasync_otto_description' => 'desc',
193 '_metasync_otto_og_title' => 'og_title',
194 '_metasync_otto_og_description' => 'og_desc',
195 '_metasync_otto_twitter_title' => 'twitter_title',
196 '_metasync_otto_twitter_description' => 'twitter_desc',
197 '_metasync_otto_keywords' => 'focus_keyword',
198 // Legacy meta box keys → rebuild JSON
199 'metasync_common_robots' => '_legacy_robots_to_json',
200 'metasync_advance_robots' => '_legacy_robots_to_json',
201 ];
202
203 if (!isset($watched[$meta_key])) {
204 return;
205 }
206
207 $canonical_key = $watched[$meta_key];
208
209 // JSON key -- do a full sync + mirror to legacy meta boxes
210 if ($canonical_key === '_robots_advanced_json') {
211 $this->sync_post((int) $post_id);
212 $this->sync_to_legacy_meta((int) $post_id, $meta_value);
213 // Re-sync at shutdown only when Yoast is active — Yoast's indexable
214 // watcher can overwrite our values during the same request.
215 if ($this->is_yoast_active()) {
216 $sync_instance = $this;
217 $sync_post_id = (int) $post_id;
218 add_action('shutdown', function() use ($sync_instance, $sync_post_id) {
219 $sync_instance->sync_post($sync_post_id);
220 }, 0);
221 }
222 return;
223 }
224
225 // Legacy meta box → rebuild _metasync_robots_advanced JSON
226 if ($canonical_key === '_legacy_robots_to_json') {
227 $this->sync_legacy_to_json((int) $post_id);
228 return;
229 }
230
231 // For noindex: convert 'noindex' string to bool
232 if ($canonical_key === 'noindex') {
233 $value = ($meta_value === 'noindex');
234 } else {
235 $value = $meta_value;
236 }
237
238 $this->sync_post(
239 (int) $post_id,
240 [$canonical_key => $value]
241 );
242 }
243
244 // ------------------------------------------------------------------
245 // Data collection
246 // ------------------------------------------------------------------
247
248 /**
249 * Collect canonical SEO data from all MetaSync post meta.
250 *
251 * Reads all meta in one get_post_custom() call for performance.
252 * When $fields is non-empty, the result is filtered to only those keys.
253 *
254 * @param int $post_id Post ID.
255 * @param array $fields Optional pre-resolved canonical key/value pairs.
256 * @return array Canonical data array.
257 */
258 private function collect_post_data($post_id, array $fields = []) {
259 // If caller already resolved specific fields, return them directly.
260 // Canonical still gets validated — this branch serves the
261 // updated_post_meta fast-path, which would otherwise mirror a raw
262 // (possibly corrupted) value into third-party storage.
263 if (!empty($fields)) {
264 if (array_key_exists('canonical', $fields)) {
265 $fields['canonical'] = Metasync_Canonical_Sanitizer::sanitize($fields['canonical']);
266 if ($fields['canonical'] === '') {
267 unset($fields['canonical']);
268 }
269 }
270 return $fields;
271 }
272
273 $all_meta = get_post_custom($post_id);
274
275 $get = function ($key) use ($all_meta) {
276 if (!isset($all_meta[$key])) {
277 return '';
278 }
279 return is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
280 };
281
282 $data = [];
283
284 // Helper: first non-empty value from a list of meta keys
285 $first = function (...$keys) use ($get) {
286 foreach ($keys as $key) {
287 $val = $get($key);
288 if (!empty($val)) {
289 return $val;
290 }
291 }
292 return '';
293 };
294
295 // title: sidebar > persisted OTTO > volatile OTTO
296 $data['title'] = $first('_metasync_seo_title', '_metasync_metatitle', '_metasync_otto_title');
297
298 // desc: sidebar > persisted OTTO > volatile OTTO
299 $data['desc'] = $first('_metasync_seo_desc', '_metasync_metadesc', '_metasync_otto_description');
300
301 // Robots directives: check _metasync_robots_advanced JSON first,
302 // then fall back to metasync_common_robots array + metasync_advance_robots array
303 $robots_json_raw = $get('_metasync_robots_advanced');
304 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
305
306 if (is_array($robots_json)) {
307 $data['noindex'] = !empty($robots_json['noindex']);
308 $data['nofollow'] = !empty($robots_json['nofollow']);
309 $data['noarchive'] = !empty($robots_json['noarchive']);
310 $data['nosnippet'] = !empty($robots_json['nosnippet']);
311 $data['noimageindex'] = !empty($robots_json['noimageindex']);
312 $data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null);
313 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
314 $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);
315 } else {
316 // noindex from dedicated key
317 $robots_index = $get('_metasync_robots_index');
318 $data['noindex'] = ($robots_index === 'noindex');
319
320 // Common robots array (serialized)
321 $common_raw = $get('metasync_common_robots');
322 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
323 if (!is_array($common_robots)) {
324 $common_robots = [];
325 }
326
327 $data['nofollow'] = !empty($common_robots['nofollow']);
328 $data['noarchive'] = !empty($common_robots['noarchive']);
329 $data['nosnippet'] = !empty($common_robots['nosnippet']);
330 $data['noimageindex'] = !empty($common_robots['noimageindex']);
331
332 // Advance robots array (serialized)
333 $adv_raw = $get('metasync_advance_robots');
334 $adv_robots = !empty($adv_raw) ? maybe_unserialize($adv_raw) : [];
335 if (!is_array($adv_robots)) {
336 $adv_robots = [];
337 }
338
339 // Each advance-robots directive is stored as ['enable' => .., 'length' => ..].
340 // Read the length scalar (honouring the enable flag) instead of casting the
341 // whole sub-array — (int) of a non-empty array is 1, which is what produced the
342 // "max-snippet:1" instead of "-1" and dropped the image-preview value. Mirrors
343 // sync_legacy_to_json() so both directions read the legacy format identically.
344 $data['max_snippet'] = !empty($adv_robots['max-snippet']['enable'])
345 ? (isset($adv_robots['max-snippet']['length']) ? (int) $adv_robots['max-snippet']['length'] : -1)
346 : null;
347 $data['max_image_preview'] = !empty($adv_robots['max-image-preview']['enable'])
348 ? (isset($adv_robots['max-image-preview']['length']) ? (string) $adv_robots['max-image-preview']['length'] : 'large')
349 : null;
350 $data['max_video_preview'] = !empty($adv_robots['max-video-preview']['enable'])
351 ? (isset($adv_robots['max-video-preview']['length']) ? (int) $adv_robots['max-video-preview']['length'] : -1)
352 : null;
353 }
354
355 // Social / OG: persisted > volatile OTTO
356 $data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title');
357 $data['og_desc'] = $first('_metasync_og_description', '_metasync_otto_og_description');
358 $data['og_image'] = $get('_metasync_og_image');
359
360 // Twitter: persisted > volatile OTTO
361 $data['twitter_title'] = $first('_metasync_twitter_title', '_metasync_otto_twitter_title');
362 $data['twitter_desc'] = $first('_metasync_twitter_description', '_metasync_otto_twitter_description');
363 $data['twitter_card'] = $get('_metasync_twitter_card');
364
365 // Canonical, focus keyword, breadcrumb
366 // Canonical is validated at the source so a corrupted value ("Array")
367 // never propagates into Yoast/RankMath/AIOSEO storage.
368 $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url'));
369 $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
370 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
371
372 return $data;
373 }
374
375 // ------------------------------------------------------------------
376 // Plugin detectors
377 // ------------------------------------------------------------------
378
379 /**
380 * Check whether Yoast SEO (free or premium) is active.
381 *
382 * @return bool
383 */
384 private function is_yoast_active() {
385 $this->ensure_plugin_api();
386 return is_plugin_active('wordpress-seo/wp-seo.php')
387 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
388 }
389
390 /**
391 * Check whether Rank Math SEO is active.
392 *
393 * @return bool
394 */
395 private function is_rankmath_active() {
396 $this->ensure_plugin_api();
397 return is_plugin_active('seo-by-rank-math/rank-math.php')
398 || is_plugin_active('seo-by-rankmath/rank-math.php');
399 }
400
401 /**
402 * Check whether AIOSEO (free or pro) is active.
403 *
404 * @return bool
405 */
406 private function is_aioseo_active() {
407 $this->ensure_plugin_api();
408 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
409 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
410 }
411
412 /**
413 * Ensure is_plugin_active() is loaded on the frontend.
414 */
415 private function ensure_plugin_api() {
416 if (!function_exists('is_plugin_active')) {
417 require_once ABSPATH . 'wp-admin/includes/plugin.php';
418 }
419 }
420
421 // ------------------------------------------------------------------
422 // Per-plugin sync
423 // ------------------------------------------------------------------
424
425 /**
426 * Mirror canonical data into Yoast post meta and indexable cache.
427 *
428 * @param int $post_id Post ID.
429 * @param array $data Canonical key/value pairs.
430 * @return bool True once dispatch completes.
431 */
432 private function sync_yoast($post_id, array $data) {
433 // title
434 if (!empty($data['title'])) {
435 update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
436 }
437
438 // description -- strip newlines first
439 if (!empty($data['desc'])) {
440 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
441 update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
442 }
443
444 // noindex: '0'=default, '1'=noindex, '2'=index
445 if (array_key_exists('noindex', $data)) {
446 $val = $data['noindex'] ? '1' : '2';
447 update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
448 }
449
450 // nofollow: '0'=follow, '1'=nofollow
451 if (array_key_exists('nofollow', $data)) {
452 update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
453 }
454
455 // advanced robots: comma-separated NO spaces
456 $adv = [];
457 if (!empty($data['noarchive'])) {
458 $adv[] = 'noarchive';
459 }
460 if (!empty($data['nosnippet'])) {
461 $adv[] = 'nosnippet';
462 }
463 if (!empty($data['noimageindex'])) {
464 $adv[] = 'noimageindex';
465 }
466 update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
467
468 // OG
469 if (!empty($data['og_title'])) {
470 update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
471 }
472 if (!empty($data['og_desc'])) {
473 update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
474 }
475 if (!empty($data['og_image'])) {
476 update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
477 }
478
479 // Twitter
480 if (!empty($data['twitter_title'])) {
481 update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
482 }
483 if (!empty($data['twitter_desc'])) {
484 update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
485 }
486
487 // Canonical, focus keyword, breadcrumb
488 if (!empty($data['canonical'])) {
489 update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
490 }
491 if (!empty($data['focus_keyword'])) {
492 update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
493 }
494 if (!empty($data['breadcrumb_title'])) {
495 update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
496 }
497
498 // Update wp_yoast_indexable cache row for immediate effect
499 global $wpdb;
500 $indexable_table = $wpdb->prefix . 'yoast_indexable';
501
502 $updates = [];
503 if (!empty($data['title'])) {
504 $updates['title'] = mb_substr($data['title'], 0, 191);
505 }
506 if (!empty($data['desc'])) {
507 $updates['description'] = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
508 }
509 if (array_key_exists('noindex', $data)) {
510 $updates['is_robots_noindex'] = $data['noindex'] ? 1 : 0;
511 }
512 if (array_key_exists('nofollow', $data)) {
513 $updates['is_robots_nofollow'] = $data['nofollow'] ? 1 : 0;
514 }
515 if (array_key_exists('noarchive', $data)) {
516 $updates['is_robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
517 }
518 if (array_key_exists('nosnippet', $data)) {
519 $updates['is_robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
520 }
521 if (array_key_exists('noimageindex', $data)) {
522 $updates['is_robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
523 }
524 if (!empty($data['og_title'])) {
525 $updates['open_graph_title'] = mb_substr($data['og_title'], 0, 191);
526 }
527 if (!empty($data['og_image'])) {
528 $updates['open_graph_image'] = $data['og_image'];
529 }
530 if (!empty($data['twitter_title'])) {
531 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
532 }
533 if (!empty($data['twitter_card'])) {
534 // twitter_card column only exists in newer Yoast versions; skip if absent
535 $col_check = $wpdb->get_var($wpdb->prepare(
536 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
537 DB_NAME,
538 $indexable_table
539 ));
540 if ($col_check) {
541 $updates['twitter_card'] = $data['twitter_card'];
542 }
543 }
544 if (!empty($data['canonical'])) {
545 $updates['canonical'] = $data['canonical'];
546 }
547 if (!empty($data['focus_keyword'])) {
548 $updates['primary_focus_keyword'] = mb_substr($data['focus_keyword'], 0, 191);
549 }
550 if (!empty($data['breadcrumb_title'])) {
551 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
552 }
553
554 if (!empty($updates)) {
555 $row_exists = $wpdb->get_var($wpdb->prepare(
556 "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
557 $post_id
558 ));
559
560 if ($row_exists) {
561 $wpdb->update(
562 $indexable_table,
563 $updates,
564 ['object_id' => $post_id, 'object_type' => 'post']
565 );
566 } else {
567 $post = get_post($post_id);
568 $insert = array_merge([
569 'object_id' => $post_id,
570 'object_type' => 'post',
571 'object_sub_type' => $post ? $post->post_type : 'post',
572 'post_status' => $post ? $post->post_status : 'publish',
573 'author_id' => $post ? (int) $post->post_author : 0,
574 'is_robots_noindex' => 0,
575 'is_robots_nofollow' => 0,
576 'is_robots_noarchive' => 0,
577 'is_robots_nosnippet' => 0,
578 'is_robots_noimageindex' => 0,
579 'is_cornerstone' => 0,
580 'created_at' => current_time('mysql'),
581 'updated_at' => current_time('mysql'),
582 ], $updates);
583 $wpdb->insert($indexable_table, $insert);
584 }
585 }
586
587 return true;
588 }
589
590 /**
591 * Mirror canonical data into Rank Math post meta.
592 *
593 * @param int $post_id Post ID.
594 * @param array $data Canonical key/value pairs.
595 * @return bool True once dispatch completes.
596 */
597 private function sync_rankmath($post_id, array $data) {
598 // title, desc
599 if (!empty($data['title'])) {
600 update_post_meta($post_id, 'rank_math_title', $data['title']);
601 }
602 if (!empty($data['desc'])) {
603 update_post_meta($post_id, 'rank_math_description', $data['desc']);
604 }
605
606 // robots: PHP indexed array
607 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
608 $existing = get_post_meta($post_id, 'rank_math_robots', true);
609 $robots = is_array($existing) ? $existing : [];
610 $robots = array_values(array_diff($robots, ['index', 'noindex', 'follow', 'nofollow']));
611 if (array_key_exists('noindex', $data)) {
612 $robots[] = $data['noindex'] ? 'noindex' : 'index';
613 }
614 if (array_key_exists('nofollow', $data)) {
615 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
616 }
617 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
618 }
619
620 // Advanced robots: max-* go into rank_math_advanced_robots
621 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
622 $has_adv = false;
623 foreach ($adv_keys as $k) {
624 if (array_key_exists($k, $data)) {
625 $has_adv = true;
626 break;
627 }
628 }
629 if ($has_adv) {
630 $existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true);
631 $adv = is_array($existing_adv) ? $existing_adv : [];
632 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
633 $val = (int) $data['max_snippet'];
634 $adv['max-snippet'] = (string) $val;
635 }
636 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
637 $allowed = ['none', 'standard', 'large'];
638 if (in_array($data['max_image_preview'], $allowed, true)) {
639 $adv['max-image-preview'] = (string) $data['max_image_preview'];
640 }
641 }
642 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
643 $val = (int) $data['max_video_preview'];
644 $adv['max-video-preview'] = (string) $val;
645 }
646 if (!empty($adv)) {
647 update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
648 }
649 }
650
651 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
652 if (array_key_exists('noarchive', $data) || array_key_exists('nosnippet', $data) || array_key_exists('noimageindex', $data)) {
653 $existing = get_post_meta($post_id, 'rank_math_robots', true);
654 $robots = is_array($existing) ? $existing : [];
655 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
656 if (!array_key_exists($dir, $data)) {
657 continue;
658 }
659 $robots = array_values(array_diff($robots, [$dir]));
660 if (!empty($data[$dir])) {
661 $robots[] = $dir;
662 }
663 }
664 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
665 }
666
667 // OG
668 if (!empty($data['og_title'])) {
669 update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
670 }
671 if (!empty($data['og_desc'])) {
672 update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
673 }
674 if (!empty($data['og_image'])) {
675 update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
676 $img_id = attachment_url_to_postid($data['og_image']);
677 if ($img_id) {
678 update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
679 }
680 }
681
682 // Twitter
683 if (!empty($data['twitter_title'])) {
684 update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
685 }
686 if (!empty($data['twitter_desc'])) {
687 update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
688 }
689 if (!empty($data['twitter_card'])) {
690 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
691 if (in_array($data['twitter_card'], $valid_cards, true)) {
692 update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
693 }
694 }
695
696 // Canonical, focus keyword, breadcrumb
697 if (!empty($data['canonical'])) {
698 update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
699 }
700 if (!empty($data['focus_keyword'])) {
701 update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
702 }
703 if (!empty($data['breadcrumb_title'])) {
704 update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
705 }
706
707 return true;
708 }
709
710 /**
711 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
712 *
713 * @param int $post_id Post ID.
714 * @param array $data Canonical key/value pairs.
715 * @return bool True when the row was written, false when the table is
716 * missing or the write failed.
717 */
718 private function sync_aioseo($post_id, array $data) {
719 global $wpdb;
720
721 $table = $wpdb->prefix . 'aioseo_posts';
722
723 // Bail if the AIOSEO post table does not exist (plugin not initialised).
724 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
725 if ($table_exists !== $table) {
726 return false;
727 }
728
729 $row = [];
730
731 if (!empty($data['title'])) {
732 $row['title'] = sanitize_text_field($data['title']);
733 }
734 if (!empty($data['desc'])) {
735 $row['description'] = sanitize_text_field($data['desc']);
736 }
737 if (!empty($data['og_title'])) {
738 $row['og_title'] = sanitize_text_field($data['og_title']);
739 }
740 if (!empty($data['og_desc'])) {
741 $row['og_description'] = sanitize_text_field($data['og_desc']);
742 }
743 if (!empty($data['og_image'])) {
744 $row['og_image_type'] = 'custom';
745 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
746 }
747 if (!empty($data['twitter_title'])) {
748 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
749 }
750 if (!empty($data['twitter_desc'])) {
751 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
752 }
753 if (!empty($data['twitter_card'])) {
754 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
755 if (in_array($data['twitter_card'], $valid_cards, true)) {
756 $row['twitter_card'] = $data['twitter_card'];
757 }
758 }
759 if (!empty($data['canonical'])) {
760 $row['canonical_url'] = esc_url_raw($data['canonical']);
761 }
762
763 // focus keyword as keyphrases JSON
764 if (!empty($data['focus_keyword'])) {
765 $row['keyphrases'] = wp_json_encode([
766 'focus' => [
767 'keyphrase' => sanitize_text_field($data['focus_keyword']),
768 'score' => 0,
769 'analysis' => new \stdClass(),
770 ],
771 'additional' => [],
772 ]);
773 }
774
775 // Robots
776 $has_robots = false;
777 foreach (['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'] as $k) {
778 if (array_key_exists($k, $data)) {
779 $has_robots = true;
780 break;
781 }
782 }
783 if ($has_robots) {
784 $row['robots_default'] = 0;
785 if (array_key_exists('noindex', $data)) {
786 $row['robots_noindex'] = $data['noindex'] ? 1 : 0;
787 }
788 if (array_key_exists('nofollow', $data)) {
789 $row['robots_nofollow'] = $data['nofollow'] ? 1 : 0;
790 }
791 if (array_key_exists('noarchive', $data)) {
792 $row['robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
793 }
794 if (array_key_exists('nosnippet', $data)) {
795 $row['robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
796 }
797 if (array_key_exists('noimageindex', $data)) {
798 $row['robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
799 }
800 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
801 $row['robots_max_snippet'] = (int) $data['max_snippet'];
802 }
803 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
804 $row['robots_max_videopreview'] = (int) $data['max_video_preview'];
805 }
806 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
807 $allowed = ['none', 'standard', 'large'];
808 if (in_array($data['max_image_preview'], $allowed, true)) {
809 $row['robots_max_imagepreview'] = $data['max_image_preview'];
810 }
811 }
812 }
813
814 if (empty($row)) {
815 return false;
816 }
817
818 $row['updated'] = current_time('mysql');
819
820 $existing_id = $wpdb->get_var($wpdb->prepare(
821 "SELECT id FROM {$table} WHERE post_id = %d",
822 $post_id
823 ));
824
825 if ($existing_id) {
826 return $wpdb->update($table, $row, ['post_id' => $post_id]) !== false;
827 }
828
829 // New row -- must include all NOT NULL columns with no defaults
830 $row['post_id'] = $post_id;
831 $row['created'] = current_time('mysql');
832 $robot_defaults = [
833 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
834 'robots_noindex' => 0,
835 'robots_nofollow' => 0,
836 'robots_noarchive' => 0,
837 'robots_nosnippet' => 0,
838 'robots_noimageindex' => 0,
839 'robots_noodp' => 0,
840 'robots_notranslate' => 0,
841 ];
842 $row = array_merge($robot_defaults, $row);
843
844 return $wpdb->insert($table, $row) !== false;
845 }
846
847 // ------------------------------------------------------------------
848 // Two-way sync: sidebar JSON ↔ legacy meta boxes
849 // ------------------------------------------------------------------
850
851 /**
852 * Mirror _metasync_robots_advanced JSON → legacy meta box keys.
853 *
854 * Called when the sidebar writes the JSON key so the classic-editor
855 * meta boxes reflect the same values.
856 *
857 * @param int $post_id Post ID.
858 * @param string $json_value Raw JSON string from _metasync_robots_advanced.
859 */
860 private function sync_to_legacy_meta($post_id, $json_value) {
861 static $syncing_legacy = [];
862 if (!empty($syncing_legacy[$post_id])) {
863 return;
864 }
865 // Our own sync_legacy_to_json() write triggered this. The legacy meta
866 // box values are the source of truth in that direction, and mirroring
867 // back would drop any value this function treats as a default (-1 /
868 // large), so bail out and leave the legacy keys alone.
869 if (!empty($this->syncing_legacy_to_json[$post_id])) {
870 return;
871 }
872 $syncing_legacy[$post_id] = true;
873 // Block sync_legacy_to_json from running while we write legacy keys
874 $this->syncing_json_to_legacy[$post_id] = true;
875
876 try {
877 $robots = is_string($json_value) ? json_decode($json_value, true) : $json_value;
878 if (!is_array($robots)) {
879 return;
880 }
881
882 // Build metasync_common_robots array
883 $common = get_post_meta($post_id, 'metasync_common_robots', true);
884 if (!is_array($common)) {
885 $common = [];
886 }
887 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
888 if (!empty($robots[$dir])) {
889 $common[$dir] = $dir;
890 } else {
891 unset($common[$dir]);
892 }
893 }
894 if (!empty($common)) {
895 update_post_meta($post_id, 'metasync_common_robots', $common);
896 } else {
897 delete_post_meta($post_id, 'metasync_common_robots');
898 }
899
900 // Build metasync_advance_robots array — skip default values to keep legacy clean
901 $adv = [];
902 if (isset($robots['max_snippet']) && $robots['max_snippet'] !== null && (int) $robots['max_snippet'] !== -1) {
903 $adv['max-snippet'] = ['enable' => '1', 'length' => (string) $robots['max_snippet']];
904 }
905 if (isset($robots['max_image_preview']) && $robots['max_image_preview'] !== null && $robots['max_image_preview'] !== 'large') {
906 $adv['max-image-preview'] = ['enable' => '1', 'length' => (string) $robots['max_image_preview']];
907 }
908 if (isset($robots['max_video_preview']) && $robots['max_video_preview'] !== null && (int) $robots['max_video_preview'] !== -1) {
909 $adv['max-video-preview'] = ['enable' => '1', 'length' => (string) $robots['max_video_preview']];
910 }
911 if (!empty($adv)) {
912 update_post_meta($post_id, 'metasync_advance_robots', $adv);
913 } else {
914 delete_post_meta($post_id, 'metasync_advance_robots');
915 }
916 } finally {
917 unset($syncing_legacy[$post_id]);
918 unset($this->syncing_json_to_legacy[$post_id]);
919 }
920 }
921
922 /**
923 * Rebuild _metasync_robots_advanced JSON from legacy meta box keys.
924 *
925 * Called when the classic-editor meta boxes save metasync_common_robots
926 * or metasync_advance_robots so the sidebar JSON stays in sync.
927 *
928 * @param int $post_id Post ID.
929 */
930 private function sync_legacy_to_json($post_id) {
931 static $syncing_json = [];
932 // If sync_to_legacy_meta is currently running, skip — it already wrote the correct JSON
933 if (!empty($this->syncing_json_to_legacy[$post_id])) {
934 return;
935 }
936 if (!empty($syncing_json[$post_id])) {
937 return;
938 }
939 $syncing_json[$post_id] = true;
940 // Block sync_to_legacy_meta from reacting to the JSON write below
941 $this->syncing_legacy_to_json[$post_id] = true;
942
943 try {
944 $common = get_post_meta($post_id, 'metasync_common_robots', true);
945 if (!is_array($common)) {
946 $common = [];
947 }
948 $adv = get_post_meta($post_id, 'metasync_advance_robots', true);
949 if (!is_array($adv)) {
950 $adv = [];
951 }
952
953 $json = [];
954
955 // Boolean directives from common_robots
956 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
957 $json[$dir] = !empty($common[$dir]);
958 }
959
960 // max-* directives from advance_robots
961 if (!empty($adv['max-snippet']['enable'])) {
962 $json['max_snippet'] = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
963 }
964 if (!empty($adv['max-image-preview']['enable'])) {
965 $json['max_image_preview'] = isset($adv['max-image-preview']['length']) ? (string) $adv['max-image-preview']['length'] : 'large';
966 }
967 if (!empty($adv['max-video-preview']['enable'])) {
968 $json['max_video_preview'] = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
969 }
970
971 // Only write if there's something meaningful
972 $has_value = false;
973 foreach ($json as $v) {
974 if ($v !== false && $v !== null) {
975 $has_value = true;
976 break;
977 }
978 }
979
980 if ($has_value) {
981 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($json));
982 } else {
983 delete_post_meta($post_id, '_metasync_robots_advanced');
984 }
985 } finally {
986 unset($syncing_json[$post_id]);
987 unset($this->syncing_legacy_to_json[$post_id]);
988 }
989 }
990 }
991