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

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