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

945 lines 30.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 (WP-196)
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 // WP-197 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 if (!empty($fields)) {
247 return $fields;
248 }
249
250 $all_meta = get_post_custom($post_id);
251
252 $get = function ($key) use ($all_meta) {
253 if (!isset($all_meta[$key])) {
254 return '';
255 }
256 return is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key];
257 };
258
259 $data = [];
260
261 // Helper: first non-empty value from a list of meta keys
262 $first = function (...$keys) use ($get) {
263 foreach ($keys as $key) {
264 $val = $get($key);
265 if (!empty($val)) {
266 return $val;
267 }
268 }
269 return '';
270 };
271
272 // title: sidebar > persisted OTTO > volatile OTTO
273 $data['title'] = $first('_metasync_seo_title', '_metasync_metatitle', '_metasync_otto_title');
274
275 // desc: sidebar > persisted OTTO > volatile OTTO
276 $data['desc'] = $first('_metasync_seo_desc', '_metasync_metadesc', '_metasync_otto_description');
277
278 // Robots directives: check _metasync_robots_advanced JSON first (WP-197),
279 // then fall back to metasync_common_robots array + metasync_advance_robots array
280 $robots_json_raw = $get('_metasync_robots_advanced');
281 $robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null;
282
283 if (is_array($robots_json)) {
284 $data['noindex'] = !empty($robots_json['noindex']);
285 $data['nofollow'] = !empty($robots_json['nofollow']);
286 $data['noarchive'] = !empty($robots_json['noarchive']);
287 $data['nosnippet'] = !empty($robots_json['nosnippet']);
288 $data['noimageindex'] = !empty($robots_json['noimageindex']);
289 $data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null);
290 $data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null;
291 $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);
292 } else {
293 // noindex from dedicated key
294 $robots_index = $get('_metasync_robots_index');
295 $data['noindex'] = ($robots_index === 'noindex');
296
297 // Common robots array (serialized)
298 $common_raw = $get('metasync_common_robots');
299 $common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : [];
300 if (!is_array($common_robots)) {
301 $common_robots = [];
302 }
303
304 $data['nofollow'] = !empty($common_robots['nofollow']);
305 $data['noarchive'] = !empty($common_robots['noarchive']);
306 $data['nosnippet'] = !empty($common_robots['nosnippet']);
307 $data['noimageindex'] = !empty($common_robots['noimageindex']);
308
309 // Advance robots array (serialized)
310 $adv_raw = $get('metasync_advance_robots');
311 $adv_robots = !empty($adv_raw) ? maybe_unserialize($adv_raw) : [];
312 if (!is_array($adv_robots)) {
313 $adv_robots = [];
314 }
315
316 $data['max_snippet'] = isset($adv_robots['max-snippet']) ? (int) $adv_robots['max-snippet'] : null;
317 $data['max_image_preview'] = isset($adv_robots['max-image-preview']) ? $adv_robots['max-image-preview'] : null;
318 $data['max_video_preview'] = isset($adv_robots['max-video-preview']) ? (int) $adv_robots['max-video-preview'] : null;
319 }
320
321 // Social / OG: persisted > volatile OTTO
322 $data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title');
323 $data['og_desc'] = $first('_metasync_og_description', '_metasync_otto_og_description');
324 $data['og_image'] = $get('_metasync_og_image');
325
326 // Twitter: persisted > volatile OTTO
327 $data['twitter_title'] = $first('_metasync_twitter_title', '_metasync_otto_twitter_title');
328 $data['twitter_desc'] = $first('_metasync_twitter_description', '_metasync_otto_twitter_description');
329 $data['twitter_card'] = $get('_metasync_twitter_card');
330
331 // Canonical, focus keyword, breadcrumb
332 $data['canonical'] = $get('_metasync_canonical_url');
333 $data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords');
334 $data['breadcrumb_title'] = $get('_metasync_breadcrumb_title');
335
336 return $data;
337 }
338
339 // ------------------------------------------------------------------
340 // Plugin detectors
341 // ------------------------------------------------------------------
342
343 /**
344 * Check whether Yoast SEO (free or premium) is active.
345 *
346 * @return bool
347 */
348 private function is_yoast_active() {
349 $this->ensure_plugin_api();
350 return is_plugin_active('wordpress-seo/wp-seo.php')
351 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
352 }
353
354 /**
355 * Check whether Rank Math SEO is active.
356 *
357 * @return bool
358 */
359 private function is_rankmath_active() {
360 $this->ensure_plugin_api();
361 return is_plugin_active('seo-by-rank-math/rank-math.php')
362 || is_plugin_active('seo-by-rankmath/rank-math.php');
363 }
364
365 /**
366 * Check whether AIOSEO (free or pro) is active.
367 *
368 * @return bool
369 */
370 private function is_aioseo_active() {
371 $this->ensure_plugin_api();
372 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
373 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
374 }
375
376 /**
377 * Ensure is_plugin_active() is loaded on the frontend.
378 */
379 private function ensure_plugin_api() {
380 if (!function_exists('is_plugin_active')) {
381 require_once ABSPATH . 'wp-admin/includes/plugin.php';
382 }
383 }
384
385 // ------------------------------------------------------------------
386 // Per-plugin sync
387 // ------------------------------------------------------------------
388
389 /**
390 * Mirror canonical data into Yoast post meta and indexable cache.
391 *
392 * @param int $post_id Post ID.
393 * @param array $data Canonical key/value pairs.
394 * @return bool True once dispatch completes.
395 */
396 private function sync_yoast($post_id, array $data) {
397 // title
398 if (!empty($data['title'])) {
399 update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']);
400 }
401
402 // description -- strip newlines first
403 if (!empty($data['desc'])) {
404 $desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
405 update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
406 }
407
408 // noindex: '0'=default, '1'=noindex, '2'=index
409 if (array_key_exists('noindex', $data)) {
410 $val = $data['noindex'] ? '1' : '2';
411 update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val);
412 }
413
414 // nofollow: '0'=follow, '1'=nofollow
415 if (array_key_exists('nofollow', $data)) {
416 update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0');
417 }
418
419 // advanced robots: comma-separated NO spaces
420 $adv = [];
421 if (!empty($data['noarchive'])) {
422 $adv[] = 'noarchive';
423 }
424 if (!empty($data['nosnippet'])) {
425 $adv[] = 'nosnippet';
426 }
427 if (!empty($data['noimageindex'])) {
428 $adv[] = 'noimageindex';
429 }
430 update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv));
431
432 // OG
433 if (!empty($data['og_title'])) {
434 update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']);
435 }
436 if (!empty($data['og_desc'])) {
437 update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']);
438 }
439 if (!empty($data['og_image'])) {
440 update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image']));
441 }
442
443 // Twitter
444 if (!empty($data['twitter_title'])) {
445 update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']);
446 }
447 if (!empty($data['twitter_desc'])) {
448 update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']);
449 }
450
451 // Canonical, focus keyword, breadcrumb
452 if (!empty($data['canonical'])) {
453 update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical']));
454 }
455 if (!empty($data['focus_keyword'])) {
456 update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']);
457 }
458 if (!empty($data['breadcrumb_title'])) {
459 update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']);
460 }
461
462 // Update wp_yoast_indexable cache row for immediate effect
463 global $wpdb;
464 $indexable_table = $wpdb->prefix . 'yoast_indexable';
465
466 $updates = [];
467 if (!empty($data['title'])) {
468 $updates['title'] = mb_substr($data['title'], 0, 191);
469 }
470 if (!empty($data['desc'])) {
471 $updates['description'] = str_replace(["\n", "\r", "\t"], ' ', $data['desc']);
472 }
473 if (array_key_exists('noindex', $data)) {
474 $updates['is_robots_noindex'] = $data['noindex'] ? 1 : 0;
475 }
476 if (array_key_exists('nofollow', $data)) {
477 $updates['is_robots_nofollow'] = $data['nofollow'] ? 1 : 0;
478 }
479 if (array_key_exists('noarchive', $data)) {
480 $updates['is_robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
481 }
482 if (array_key_exists('nosnippet', $data)) {
483 $updates['is_robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
484 }
485 if (array_key_exists('noimageindex', $data)) {
486 $updates['is_robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
487 }
488 if (!empty($data['og_title'])) {
489 $updates['open_graph_title'] = mb_substr($data['og_title'], 0, 191);
490 }
491 if (!empty($data['og_image'])) {
492 $updates['open_graph_image'] = $data['og_image'];
493 }
494 if (!empty($data['twitter_title'])) {
495 $updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191);
496 }
497 if (!empty($data['twitter_card'])) {
498 // twitter_card column only exists in newer Yoast versions; skip if absent
499 $col_check = $wpdb->get_var($wpdb->prepare(
500 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'",
501 DB_NAME,
502 $indexable_table
503 ));
504 if ($col_check) {
505 $updates['twitter_card'] = $data['twitter_card'];
506 }
507 }
508 if (!empty($data['canonical'])) {
509 $updates['canonical'] = $data['canonical'];
510 }
511 if (!empty($data['focus_keyword'])) {
512 $updates['primary_focus_keyword'] = mb_substr($data['focus_keyword'], 0, 191);
513 }
514 if (!empty($data['breadcrumb_title'])) {
515 $updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191);
516 }
517
518 if (!empty($updates)) {
519 $row_exists = $wpdb->get_var($wpdb->prepare(
520 "SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'",
521 $post_id
522 ));
523
524 if ($row_exists) {
525 $wpdb->update(
526 $indexable_table,
527 $updates,
528 ['object_id' => $post_id, 'object_type' => 'post']
529 );
530 } else {
531 $post = get_post($post_id);
532 $insert = array_merge([
533 'object_id' => $post_id,
534 'object_type' => 'post',
535 'object_sub_type' => $post ? $post->post_type : 'post',
536 'post_status' => $post ? $post->post_status : 'publish',
537 'author_id' => $post ? (int) $post->post_author : 0,
538 'is_robots_noindex' => 0,
539 'is_robots_nofollow' => 0,
540 'is_robots_noarchive' => 0,
541 'is_robots_nosnippet' => 0,
542 'is_robots_noimageindex' => 0,
543 'is_cornerstone' => 0,
544 'created_at' => current_time('mysql'),
545 'updated_at' => current_time('mysql'),
546 ], $updates);
547 $wpdb->insert($indexable_table, $insert);
548 }
549 }
550
551 return true;
552 }
553
554 /**
555 * Mirror canonical data into Rank Math post meta.
556 *
557 * @param int $post_id Post ID.
558 * @param array $data Canonical key/value pairs.
559 * @return bool True once dispatch completes.
560 */
561 private function sync_rankmath($post_id, array $data) {
562 // title, desc
563 if (!empty($data['title'])) {
564 update_post_meta($post_id, 'rank_math_title', $data['title']);
565 }
566 if (!empty($data['desc'])) {
567 update_post_meta($post_id, 'rank_math_description', $data['desc']);
568 }
569
570 // robots: PHP indexed array
571 if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) {
572 $existing = get_post_meta($post_id, 'rank_math_robots', true);
573 $robots = is_array($existing) ? $existing : [];
574 $robots = array_values(array_diff($robots, ['index', 'noindex', 'follow', 'nofollow']));
575 if (array_key_exists('noindex', $data)) {
576 $robots[] = $data['noindex'] ? 'noindex' : 'index';
577 }
578 if (array_key_exists('nofollow', $data)) {
579 $robots[] = $data['nofollow'] ? 'nofollow' : 'follow';
580 }
581 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
582 }
583
584 // Advanced robots: max-* go into rank_math_advanced_robots
585 $adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview'];
586 $has_adv = false;
587 foreach ($adv_keys as $k) {
588 if (array_key_exists($k, $data)) {
589 $has_adv = true;
590 break;
591 }
592 }
593 if ($has_adv) {
594 $existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true);
595 $adv = is_array($existing_adv) ? $existing_adv : [];
596 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
597 $val = (int) $data['max_snippet'];
598 $adv['max-snippet'] = 'max-snippet:' . $val;
599 }
600 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
601 $allowed = ['none', 'standard', 'large'];
602 if (in_array($data['max_image_preview'], $allowed, true)) {
603 $adv['max-image-preview'] = 'max-image-preview:' . $data['max_image_preview'];
604 }
605 }
606 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
607 $val = (int) $data['max_video_preview'];
608 $adv['max-video-preview'] = 'max-video-preview:' . $val;
609 }
610 if (!empty($adv)) {
611 update_post_meta($post_id, 'rank_math_advanced_robots', $adv);
612 }
613 }
614
615 // Sync noarchive/nosnippet/noimageindex into rank_math_robots
616 if (array_key_exists('noarchive', $data) || array_key_exists('nosnippet', $data) || array_key_exists('noimageindex', $data)) {
617 $existing = get_post_meta($post_id, 'rank_math_robots', true);
618 $robots = is_array($existing) ? $existing : [];
619 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
620 if (!array_key_exists($dir, $data)) {
621 continue;
622 }
623 $robots = array_values(array_diff($robots, [$dir]));
624 if (!empty($data[$dir])) {
625 $robots[] = $dir;
626 }
627 }
628 update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots)));
629 }
630
631 // OG
632 if (!empty($data['og_title'])) {
633 update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']);
634 }
635 if (!empty($data['og_desc'])) {
636 update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']);
637 }
638 if (!empty($data['og_image'])) {
639 update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image']));
640 $img_id = attachment_url_to_postid($data['og_image']);
641 if ($img_id) {
642 update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id);
643 }
644 }
645
646 // Twitter
647 if (!empty($data['twitter_title'])) {
648 update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']);
649 }
650 if (!empty($data['twitter_desc'])) {
651 update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']);
652 }
653 if (!empty($data['twitter_card'])) {
654 $valid_cards = ['summary', 'summary_large_image', 'app', 'player'];
655 if (in_array($data['twitter_card'], $valid_cards, true)) {
656 update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']);
657 }
658 }
659
660 // Canonical, focus keyword, breadcrumb
661 if (!empty($data['canonical'])) {
662 update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical']));
663 }
664 if (!empty($data['focus_keyword'])) {
665 update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']);
666 }
667 if (!empty($data['breadcrumb_title'])) {
668 update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']);
669 }
670
671 return true;
672 }
673
674 /**
675 * Mirror canonical data into the AIOSEO wp_aioseo_posts custom table.
676 *
677 * @param int $post_id Post ID.
678 * @param array $data Canonical key/value pairs.
679 * @return bool True when the row was written, false when the table is
680 * missing or the write failed.
681 */
682 private function sync_aioseo($post_id, array $data) {
683 global $wpdb;
684
685 $table = $wpdb->prefix . 'aioseo_posts';
686
687 // Bail if the AIOSEO post table does not exist (plugin not initialised).
688 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
689 if ($table_exists !== $table) {
690 return false;
691 }
692
693 $row = [];
694
695 if (!empty($data['title'])) {
696 $row['title'] = sanitize_text_field($data['title']);
697 }
698 if (!empty($data['desc'])) {
699 $row['description'] = sanitize_text_field($data['desc']);
700 }
701 if (!empty($data['og_title'])) {
702 $row['og_title'] = sanitize_text_field($data['og_title']);
703 }
704 if (!empty($data['og_desc'])) {
705 $row['og_description'] = sanitize_text_field($data['og_desc']);
706 }
707 if (!empty($data['og_image'])) {
708 $row['og_image_type'] = 'custom';
709 $row['og_image_custom_url'] = esc_url_raw($data['og_image']);
710 }
711 if (!empty($data['twitter_title'])) {
712 $row['twitter_title'] = sanitize_text_field($data['twitter_title']);
713 }
714 if (!empty($data['twitter_desc'])) {
715 $row['twitter_description'] = sanitize_text_field($data['twitter_desc']);
716 }
717 if (!empty($data['twitter_card'])) {
718 $valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app'];
719 if (in_array($data['twitter_card'], $valid_cards, true)) {
720 $row['twitter_card'] = $data['twitter_card'];
721 }
722 }
723 if (!empty($data['canonical'])) {
724 $row['canonical_url'] = esc_url_raw($data['canonical']);
725 }
726
727 // focus keyword as keyphrases JSON
728 if (!empty($data['focus_keyword'])) {
729 $row['keyphrases'] = wp_json_encode([
730 'focus' => [
731 'keyphrase' => sanitize_text_field($data['focus_keyword']),
732 'score' => 0,
733 'analysis' => new \stdClass(),
734 ],
735 'additional' => [],
736 ]);
737 }
738
739 // Robots
740 $has_robots = false;
741 foreach (['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'] as $k) {
742 if (array_key_exists($k, $data)) {
743 $has_robots = true;
744 break;
745 }
746 }
747 if ($has_robots) {
748 $row['robots_default'] = 0;
749 if (array_key_exists('noindex', $data)) {
750 $row['robots_noindex'] = $data['noindex'] ? 1 : 0;
751 }
752 if (array_key_exists('nofollow', $data)) {
753 $row['robots_nofollow'] = $data['nofollow'] ? 1 : 0;
754 }
755 if (array_key_exists('noarchive', $data)) {
756 $row['robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0;
757 }
758 if (array_key_exists('nosnippet', $data)) {
759 $row['robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0;
760 }
761 if (array_key_exists('noimageindex', $data)) {
762 $row['robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0;
763 }
764 if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) {
765 $row['robots_max_snippet'] = (int) $data['max_snippet'];
766 }
767 if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) {
768 $row['robots_max_videopreview'] = (int) $data['max_video_preview'];
769 }
770 if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) {
771 $allowed = ['none', 'standard', 'large'];
772 if (in_array($data['max_image_preview'], $allowed, true)) {
773 $row['robots_max_imagepreview'] = $data['max_image_preview'];
774 }
775 }
776 }
777
778 if (empty($row)) {
779 return false;
780 }
781
782 $row['updated'] = current_time('mysql');
783
784 $existing_id = $wpdb->get_var($wpdb->prepare(
785 "SELECT id FROM {$table} WHERE post_id = %d",
786 $post_id
787 ));
788
789 if ($existing_id) {
790 return $wpdb->update($table, $row, ['post_id' => $post_id]) !== false;
791 }
792
793 // New row -- must include all NOT NULL columns with no defaults
794 $row['post_id'] = $post_id;
795 $row['created'] = current_time('mysql');
796 $robot_defaults = [
797 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
798 'robots_noindex' => 0,
799 'robots_nofollow' => 0,
800 'robots_noarchive' => 0,
801 'robots_nosnippet' => 0,
802 'robots_noimageindex' => 0,
803 'robots_noodp' => 0,
804 'robots_notranslate' => 0,
805 ];
806 $row = array_merge($robot_defaults, $row);
807
808 return $wpdb->insert($table, $row) !== false;
809 }
810
811 // ------------------------------------------------------------------
812 // Two-way sync: sidebar JSON ↔ legacy meta boxes
813 // ------------------------------------------------------------------
814
815 /**
816 * Mirror _metasync_robots_advanced JSON → legacy meta box keys.
817 *
818 * Called when the sidebar writes the JSON key so the classic-editor
819 * meta boxes reflect the same values.
820 *
821 * @param int $post_id Post ID.
822 * @param string $json_value Raw JSON string from _metasync_robots_advanced.
823 */
824 private function sync_to_legacy_meta($post_id, $json_value) {
825 static $syncing_legacy = [];
826 if (!empty($syncing_legacy[$post_id])) {
827 return;
828 }
829 $syncing_legacy[$post_id] = true;
830 // Block sync_legacy_to_json from running while we write legacy keys
831 $this->syncing_json_to_legacy[$post_id] = true;
832
833 try {
834 $robots = is_string($json_value) ? json_decode($json_value, true) : $json_value;
835 if (!is_array($robots)) {
836 return;
837 }
838
839 // Build metasync_common_robots array
840 $common = get_post_meta($post_id, 'metasync_common_robots', true);
841 if (!is_array($common)) {
842 $common = [];
843 }
844 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
845 if (!empty($robots[$dir])) {
846 $common[$dir] = $dir;
847 } else {
848 unset($common[$dir]);
849 }
850 }
851 if (!empty($common)) {
852 update_post_meta($post_id, 'metasync_common_robots', $common);
853 } else {
854 delete_post_meta($post_id, 'metasync_common_robots');
855 }
856
857 // Build metasync_advance_robots array — skip default values to keep legacy clean
858 $adv = [];
859 if (isset($robots['max_snippet']) && $robots['max_snippet'] !== null && (int) $robots['max_snippet'] !== -1) {
860 $adv['max-snippet'] = ['enable' => '1', 'length' => (string) $robots['max_snippet']];
861 }
862 if (isset($robots['max_image_preview']) && $robots['max_image_preview'] !== null && $robots['max_image_preview'] !== 'large') {
863 $adv['max-image-preview'] = ['enable' => '1', 'length' => (string) $robots['max_image_preview']];
864 }
865 if (isset($robots['max_video_preview']) && $robots['max_video_preview'] !== null && (int) $robots['max_video_preview'] !== -1) {
866 $adv['max-video-preview'] = ['enable' => '1', 'length' => (string) $robots['max_video_preview']];
867 }
868 if (!empty($adv)) {
869 update_post_meta($post_id, 'metasync_advance_robots', $adv);
870 } else {
871 delete_post_meta($post_id, 'metasync_advance_robots');
872 }
873 } finally {
874 unset($syncing_legacy[$post_id]);
875 unset($this->syncing_json_to_legacy[$post_id]);
876 }
877 }
878
879 /**
880 * Rebuild _metasync_robots_advanced JSON from legacy meta box keys.
881 *
882 * Called when the classic-editor meta boxes save metasync_common_robots
883 * or metasync_advance_robots so the sidebar JSON stays in sync.
884 *
885 * @param int $post_id Post ID.
886 */
887 private function sync_legacy_to_json($post_id) {
888 static $syncing_json = [];
889 // If sync_to_legacy_meta is currently running, skip — it already wrote the correct JSON
890 if (!empty($this->syncing_json_to_legacy[$post_id])) {
891 return;
892 }
893 if (!empty($syncing_json[$post_id])) {
894 return;
895 }
896 $syncing_json[$post_id] = true;
897
898 try {
899 $common = get_post_meta($post_id, 'metasync_common_robots', true);
900 if (!is_array($common)) {
901 $common = [];
902 }
903 $adv = get_post_meta($post_id, 'metasync_advance_robots', true);
904 if (!is_array($adv)) {
905 $adv = [];
906 }
907
908 $json = [];
909
910 // Boolean directives from common_robots
911 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
912 $json[$dir] = !empty($common[$dir]);
913 }
914
915 // max-* directives from advance_robots
916 if (!empty($adv['max-snippet']['enable'])) {
917 $json['max_snippet'] = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
918 }
919 if (!empty($adv['max-image-preview']['enable'])) {
920 $json['max_image_preview'] = isset($adv['max-image-preview']['length']) ? (string) $adv['max-image-preview']['length'] : 'large';
921 }
922 if (!empty($adv['max-video-preview']['enable'])) {
923 $json['max_video_preview'] = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
924 }
925
926 // Only write if there's something meaningful
927 $has_value = false;
928 foreach ($json as $v) {
929 if ($v !== false && $v !== null) {
930 $has_value = true;
931 break;
932 }
933 }
934
935 if ($has_value) {
936 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($json));
937 } else {
938 delete_post_meta($post_id, '_metasync_robots_advanced');
939 }
940 } finally {
941 unset($syncing_json[$post_id]);
942 }
943 }
944 }
945