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

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