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

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