| 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 |
// By shutdown this request has typically seeded the OG defaults |
| 288 |
// memo for the post (the sync above reads it); the row write |
| 289 |
// has since landed, so drop the memo or the re-sync below |
| 290 |
// compares against the pre-save title/excerpt. |
| 291 |
// @phpstan-ignore-next-line function.alreadyNarrowedType |
| 292 |
if (method_exists('Metasync_OpenGraph', 'clear_default_og_values_memo')) { |
| 293 |
Metasync_OpenGraph::clear_default_og_values_memo(); |
| 294 |
} |
| 295 |
$sync_instance->sync_post($sync_post_id); |
| 296 |
}, 0); |
| 297 |
} |
| 298 |
return; |
| 299 |
} |
| 300 |
|
| 301 |
// Legacy meta box → rebuild _metasync_robots_advanced JSON |
| 302 |
if ($canonical_key === '_legacy_robots_to_json') { |
| 303 |
$this->sync_legacy_to_json((int) $post_id); |
| 304 |
return; |
| 305 |
} |
| 306 |
|
| 307 |
// For noindex: convert 'noindex' string to bool |
| 308 |
if ($canonical_key === 'noindex') { |
| 309 |
$value = ($meta_value === 'noindex'); |
| 310 |
} else { |
| 311 |
$value = $meta_value; |
| 312 |
} |
| 313 |
|
| 314 |
$this->sync_post( |
| 315 |
(int) $post_id, |
| 316 |
[$canonical_key => $value] |
| 317 |
); |
| 318 |
} |
| 319 |
|
| 320 |
/** |
| 321 |
* Hook handler for deleted_post_meta. |
| 322 |
* |
| 323 |
* Only the legacy meta box keys are handled here. Unticking the last |
| 324 |
* checkbox in the Common Robots meta box deletes metasync_common_robots |
| 325 |
* instead of updating it, so without a delete hook the mirrored |
| 326 |
* _metasync_robots_advanced JSON kept the stale directive — and because the |
| 327 |
* JSON is the highest-priority source in the output resolver, the page went |
| 328 |
* on emitting a directive the editor had just cleared. |
| 329 |
* |
| 330 |
* Deletes of the JSON key itself are deliberately NOT routed into |
| 331 |
* on_meta_updated: that path mirrors the value back onto the legacy meta |
| 332 |
* boxes, so an empty value would wipe them. |
| 333 |
* |
| 334 |
* @param array $meta_ids Meta row IDs (unused). |
| 335 |
* @param int $post_id Post ID. |
| 336 |
* @param string $meta_key Meta key being deleted. |
| 337 |
*/ |
| 338 |
public function on_meta_deleted($meta_ids, $post_id, $meta_key) { |
| 339 |
if ($meta_key !== 'metasync_common_robots' && $meta_key !== 'metasync_advance_robots') { |
| 340 |
return; |
| 341 |
} |
| 342 |
|
| 343 |
$this->sync_legacy_to_json((int) $post_id); |
| 344 |
} |
| 345 |
|
| 346 |
// ------------------------------------------------------------------ |
| 347 |
// Data collection |
| 348 |
// ------------------------------------------------------------------ |
| 349 |
|
| 350 |
/** |
| 351 |
* Collect canonical SEO data from all MetaSync post meta. |
| 352 |
* |
| 353 |
* Reads all meta in one get_post_custom() call for performance. |
| 354 |
* When $fields is non-empty, the result is filtered to only those keys. |
| 355 |
* |
| 356 |
* @param int $post_id Post ID. |
| 357 |
* @param array $fields Optional pre-resolved canonical key/value pairs. |
| 358 |
* @return array Canonical data array. |
| 359 |
*/ |
| 360 |
private function collect_post_data($post_id, array $fields = []) { |
| 361 |
// If caller already resolved specific fields, return them directly. |
| 362 |
// Canonical still gets validated — this branch serves the |
| 363 |
// updated_post_meta fast-path, which would otherwise mirror a raw |
| 364 |
// (possibly corrupted) value into third-party storage. |
| 365 |
if (!empty($fields)) { |
| 366 |
if (array_key_exists('canonical', $fields)) { |
| 367 |
$fields['canonical'] = Metasync_Canonical_Sanitizer::sanitize($fields['canonical']); |
| 368 |
if ($fields['canonical'] === '') { |
| 369 |
unset($fields['canonical']); |
| 370 |
} |
| 371 |
} |
| 372 |
|
| 373 |
// Same reasoning for the social title/description fields: on this |
| 374 |
// fast-path the value arrives straight from the meta write, so a stored |
| 375 |
// "Auto Draft" pre-fill placeholder would be mirrored verbatim into |
| 376 |
// Yoast/RankMath/AIOSEO and emitted there as og:title. Drop it instead so |
| 377 |
// each plugin keeps whatever it already has. |
| 378 |
// @phpstan-ignore-next-line function.alreadyNarrowedType |
| 379 |
if (method_exists('Metasync_OpenGraph', 'is_auto_draft_title')) { |
| 380 |
foreach (['og_title', 'og_desc', 'twitter_title', 'twitter_desc'] as $social_field) { |
| 381 |
if (array_key_exists($social_field, $fields) |
| 382 |
&& Metasync_OpenGraph::is_auto_draft_title($fields[$social_field]) |
| 383 |
) { |
| 384 |
unset($fields[$social_field]); |
| 385 |
} |
| 386 |
} |
| 387 |
} |
| 388 |
|
| 389 |
return $this->apply_feature_flags_to_payload($fields); |
| 390 |
} |
| 391 |
|
| 392 |
$all_meta = get_post_custom($post_id); |
| 393 |
|
| 394 |
$get = function ($key) use ($all_meta, $post_id) { |
| 395 |
if (!isset($all_meta[$key])) { |
| 396 |
return ''; |
| 397 |
} |
| 398 |
$value = is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key]; |
| 399 |
|
| 400 |
// The meta box pre-fills the social title/description fields from the post |
| 401 |
// title, which is the "Auto Draft" placeholder on a brand-new post. Collapse |
| 402 |
// it to '' here so the placeholder is never mirrored into Yoast/RankMath/ |
| 403 |
// AIOSEO storage, where those plugins would emit it as og:title. |
| 404 |
// Same sanitize-at-the-source placement as Metasync_Canonical_Sanitizer below. |
| 405 |
// |
| 406 |
// method_exists (not just class_exists) for the same reason |
| 407 |
// sync_layer_handles() checks: this runs on meta writes during front-end |
| 408 |
// requests, and a partially updated install can leave an older |
| 409 |
// class-metasync-opengraph.php beside this file. |
| 410 |
// @phpstan-ignore-next-line function.alreadyNarrowedType |
| 411 |
if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title') |
| 412 |
&& defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS') |
| 413 |
&& in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true) |
| 414 |
) { |
| 415 |
$value = Metasync_OpenGraph::strip_auto_draft_title($value); |
| 416 |
} |
| 417 |
|
| 418 |
// A social title that is a verbatim snapshot of the post title is the |
| 419 |
// old pre-fill, not a customization — mirroring it would hand a |
| 420 |
// third-party plugin a copy that a rename leaves stale (and that |
| 421 |
// outranks OTTO's fresh value there). Collapsed for the same reason |
| 422 |
// as the placeholder above; the chain in $first then falls through |
| 423 |
// to OTTO's staging key. |
| 424 |
// @phpstan-ignore-next-line function.alreadyNarrowedType |
| 425 |
if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot') |
| 426 |
&& defined('Metasync_OpenGraph::TITLE_DEFAULTED_KEYS') |
| 427 |
&& in_array($key, Metasync_OpenGraph::TITLE_DEFAULTED_KEYS, true) |
| 428 |
) { |
| 429 |
$value = Metasync_OpenGraph::strip_title_snapshot($post_id, $key, $value); |
| 430 |
} |
| 431 |
|
| 432 |
// A social description that is a verbatim snapshot of the resolved |
| 433 |
// excerpt is the old pre-fill, not a customization — mirroring it |
| 434 |
// would hand a third-party plugin a copy that a later excerpt or |
| 435 |
// content edit leaves stale. Collapsed for the same reason as the |
| 436 |
// title snapshot above. |
| 437 |
// @phpstan-ignore-next-line function.alreadyNarrowedType |
| 438 |
if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot') |
| 439 |
&& defined('Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS') |
| 440 |
&& in_array($key, Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS, true) |
| 441 |
) { |
| 442 |
$value = Metasync_OpenGraph::strip_description_snapshot($post_id, $key, $value); |
| 443 |
} |
| 444 |
|
| 445 |
return $value; |
| 446 |
}; |
| 447 |
|
| 448 |
$data = []; |
| 449 |
|
| 450 |
// Helper: first non-empty value from a list of meta keys. |
| 451 |
// |
| 452 |
// Internal OTTO keys are skipped while their OTTO Persistence setting is |
| 453 |
// disabled. Gating here and not only in on_meta_updated() is what makes |
| 454 |
// the setting hold for the callers that re-read meta themselves rather |
| 455 |
// than passing a resolved field: the sync at the end of OTTO SSR |
| 456 |
// processing (metasync_update_comprehensive_seo_fields), the MCP OTTO |
| 457 |
// refresh tool, and the robots full-sync branch below. |
| 458 |
// |
| 459 |
// Each chain lists the manually edited key first and the internal OTTO |
| 460 |
// key last, so a customer-entered value is unaffected and still wins. |
| 461 |
$first = function (...$keys) use ($get) { |
| 462 |
foreach ($keys as $key) { |
| 463 |
if (!$this->otto_persistence_allows($key)) { |
| 464 |
continue; |
| 465 |
} |
| 466 |
$val = $get($key); |
| 467 |
if (!empty($val)) { |
| 468 |
return $val; |
| 469 |
} |
| 470 |
} |
| 471 |
return ''; |
| 472 |
}; |
| 473 |
|
| 474 |
// Order comes from Metasync_Seo_Precedence, the one place it is defined. |
| 475 |
// |
| 476 |
// Imported values come last so a sync triggered by something else still |
| 477 |
// reports a title rather than an empty one. They are deliberately NOT in |
| 478 |
// WATCHED_KEYS or the on_meta_updated map above: a sync mirrors the |
| 479 |
// canonical value into an active third-party plugin's own storage, and |
| 480 |
// that plugin renders at a higher effective precedence than OTTO — so |
| 481 |
// syncing on an imported write would put the imported title back in |
| 482 |
// front of OTTO, which is the exact behaviour this key exists to avoid. |
| 483 |
$data['title'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_TITLE)); |
| 484 |
|
| 485 |
$data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION)); |
| 486 |
|
| 487 |
// Robots directives: check _metasync_robots_advanced JSON first, |
| 488 |
// then fall back to metasync_common_robots array + metasync_advance_robots array |
| 489 |
$robots_json_raw = $get('_metasync_robots_advanced'); |
| 490 |
$robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null; |
| 491 |
|
| 492 |
if (is_array($robots_json)) { |
| 493 |
$data['noindex'] = $this->resolve_synced_noindex( |
| 494 |
$robots_json, |
| 495 |
$get('_metasync_robots_index'), |
| 496 |
$get('metasync_common_robots') |
| 497 |
); |
| 498 |
$data['nofollow'] = !empty($robots_json['nofollow']); |
| 499 |
$data['noarchive'] = !empty($robots_json['noarchive']); |
| 500 |
$data['nosnippet'] = !empty($robots_json['nosnippet']); |
| 501 |
$data['noimageindex'] = !empty($robots_json['noimageindex']); |
| 502 |
$data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null); |
| 503 |
$data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null; |
| 504 |
$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); |
| 505 |
} else { |
| 506 |
// noindex: dedicated key, falling back to the Common Robots checkbox |
| 507 |
// array — same sources the front-end emitter honours. |
| 508 |
$data['noindex'] = $this->resolve_synced_noindex( |
| 509 |
null, |
| 510 |
$get('_metasync_robots_index'), |
| 511 |
$get('metasync_common_robots') |
| 512 |
); |
| 513 |
|
| 514 |
// Common robots array (serialized) |
| 515 |
$common_raw = $get('metasync_common_robots'); |
| 516 |
$common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : []; |
| 517 |
if (!is_array($common_robots)) { |
| 518 |
$common_robots = []; |
| 519 |
} |
| 520 |
|
| 521 |
$data['nofollow'] = !empty($common_robots['nofollow']); |
| 522 |
$data['noarchive'] = !empty($common_robots['noarchive']); |
| 523 |
$data['nosnippet'] = !empty($common_robots['nosnippet']); |
| 524 |
$data['noimageindex'] = !empty($common_robots['noimageindex']); |
| 525 |
|
| 526 |
// Advance robots array (serialized) |
| 527 |
$adv_raw = $get('metasync_advance_robots'); |
| 528 |
$adv_robots = !empty($adv_raw) ? maybe_unserialize($adv_raw) : []; |
| 529 |
if (!is_array($adv_robots)) { |
| 530 |
$adv_robots = []; |
| 531 |
} |
| 532 |
|
| 533 |
// Each advance-robots directive is stored as ['enable' => .., 'length' => ..]. |
| 534 |
// Read the length scalar (honouring the enable flag) instead of casting the |
| 535 |
// whole sub-array — (int) of a non-empty array is 1, which is what produced the |
| 536 |
// "max-snippet:1" instead of "-1" and dropped the image-preview value. Mirrors |
| 537 |
// sync_legacy_to_json() so both directions read the legacy format identically. |
| 538 |
$data['max_snippet'] = !empty($adv_robots['max-snippet']['enable']) |
| 539 |
? (isset($adv_robots['max-snippet']['length']) ? (int) $adv_robots['max-snippet']['length'] : -1) |
| 540 |
: null; |
| 541 |
$data['max_image_preview'] = !empty($adv_robots['max-image-preview']['enable']) |
| 542 |
? (isset($adv_robots['max-image-preview']['length']) ? (string) $adv_robots['max-image-preview']['length'] : 'large') |
| 543 |
: null; |
| 544 |
$data['max_video_preview'] = !empty($adv_robots['max-video-preview']['enable']) |
| 545 |
? (isset($adv_robots['max-video-preview']['length']) ? (int) $adv_robots['max-video-preview']['length'] : -1) |
| 546 |
: null; |
| 547 |
} |
| 548 |
|
| 549 |
// Social / OG: persisted > volatile OTTO |
| 550 |
$data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title'); |
| 551 |
$data['og_desc'] = $first('_metasync_og_description', '_metasync_otto_og_description'); |
| 552 |
$data['og_image'] = $get('_metasync_og_image'); |
| 553 |
|
| 554 |
// Twitter: persisted > volatile OTTO |
| 555 |
$data['twitter_title'] = $first('_metasync_twitter_title', '_metasync_otto_twitter_title'); |
| 556 |
$data['twitter_desc'] = $first('_metasync_twitter_description', '_metasync_otto_twitter_description'); |
| 557 |
$data['twitter_card'] = $get('_metasync_twitter_card'); |
| 558 |
|
| 559 |
// Canonical, focus keyword, breadcrumb |
| 560 |
// Canonical is validated at the source so a corrupted value ("Array") |
| 561 |
// never propagates into Yoast/RankMath/AIOSEO storage. |
| 562 |
$data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url')); |
| 563 |
$data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords'); |
| 564 |
$data['breadcrumb_title'] = $get('_metasync_breadcrumb_title'); |
| 565 |
|
| 566 |
return $this->apply_feature_flags_to_payload($data); |
| 567 |
} |
| 568 |
|
| 569 |
/** |
| 570 |
* Drop payload keys whose owning feature is switched off in Editor Settings. |
| 571 |
* |
| 572 |
* A disabled meta box means MetaSync must not interfere with that feature |
| 573 |
* anywhere: it emits nothing, suppresses nothing, and — the part this |
| 574 |
* guard covers — does not mirror its values into Yoast / Rank Math / |
| 575 |
* AIOSEO storage. Without the drop, a sync fired while the feature is off |
| 576 |
* resolves the MetaSync value with the feature already stripped and then |
| 577 |
* writes that result over the third-party plugin's own value (Rank Math's |
| 578 |
* stored noindex replaced by a plain 'index'). The writers are all |
| 579 |
* array_key_exists-guarded, so removing a key here skips every write of it. |
| 580 |
* |
| 581 |
* @param array $data Canonical payload keyed by canonical field name. |
| 582 |
* @return array |
| 583 |
*/ |
| 584 |
private function apply_feature_flags_to_payload(array $data) { |
| 585 |
if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) { |
| 586 |
unset( |
| 587 |
$data['noindex'], |
| 588 |
$data['nofollow'], |
| 589 |
$data['noarchive'], |
| 590 |
$data['nosnippet'], |
| 591 |
$data['noimageindex'] |
| 592 |
); |
| 593 |
} |
| 594 |
if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::ADVANCE_ROBOTS)) { |
| 595 |
unset( |
| 596 |
$data['max_snippet'], |
| 597 |
$data['max_image_preview'], |
| 598 |
$data['max_video_preview'] |
| 599 |
); |
| 600 |
} |
| 601 |
if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) { |
| 602 |
unset( |
| 603 |
$data['og_title'], |
| 604 |
$data['og_desc'], |
| 605 |
$data['og_image'], |
| 606 |
$data['twitter_title'], |
| 607 |
$data['twitter_desc'], |
| 608 |
$data['twitter_card'] |
| 609 |
); |
| 610 |
} |
| 611 |
if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) { |
| 612 |
unset($data['canonical']); |
| 613 |
} |
| 614 |
|
| 615 |
return $data; |
| 616 |
} |
| 617 |
|
| 618 |
/** |
| 619 |
* Resolve the noindex directive for syncing the way the front-end emitter |
| 620 |
* does: any of the three sources saying noindex wins, and none can veto |
| 621 |
* another. The JSON rebuilt by sync_legacy_to_json() deliberately omits |
| 622 |
* noindex, so reading the JSON alone reported a checkbox-only noindex as |
| 623 |
* false — and the writers translated that into an explicit 'index' that |
| 624 |
* overwrote a noindex the third-party plugin already held. |
| 625 |
* |
| 626 |
* @param mixed $robots_json Decoded _metasync_robots_advanced payload (or null). |
| 627 |
* @param mixed $robots_index Raw _metasync_robots_index meta value. |
| 628 |
* @param mixed $common_raw Raw metasync_common_robots meta value. |
| 629 |
* @return bool |
| 630 |
*/ |
| 631 |
private function resolve_synced_noindex($robots_json, $robots_index, $common_raw) { |
| 632 |
if (is_array($robots_json) && !empty($robots_json['noindex'])) { |
| 633 |
return true; |
| 634 |
} |
| 635 |
if ($robots_index === 'noindex') { |
| 636 |
return true; |
| 637 |
} |
| 638 |
if (!empty($common_raw)) { |
| 639 |
$common = maybe_unserialize($common_raw); |
| 640 |
if (is_array($common) && !empty($common['noindex'])) { |
| 641 |
return true; |
| 642 |
} |
| 643 |
} |
| 644 |
|
| 645 |
return false; |
| 646 |
} |
| 647 |
|
| 648 |
// ------------------------------------------------------------------ |
| 649 |
// Plugin detectors |
| 650 |
// ------------------------------------------------------------------ |
| 651 |
|
| 652 |
/** |
| 653 |
* Check whether Yoast SEO (free or premium) is active. |
| 654 |
* |
| 655 |
* @return bool |
| 656 |
*/ |
| 657 |
private function is_yoast_active() { |
| 658 |
$this->ensure_plugin_api(); |
| 659 |
return is_plugin_active('wordpress-seo/wp-seo.php') |
| 660 |
|| is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'); |
| 661 |
} |
| 662 |
|
| 663 |
/** |
| 664 |
* Check whether Rank Math SEO is active. |
| 665 |
* |
| 666 |
* @return bool |
| 667 |
*/ |
| 668 |
private function is_rankmath_active() { |
| 669 |
$this->ensure_plugin_api(); |
| 670 |
return is_plugin_active('seo-by-rank-math/rank-math.php') |
| 671 |
|| is_plugin_active('seo-by-rankmath/rank-math.php'); |
| 672 |
} |
| 673 |
|
| 674 |
/** |
| 675 |
* Check whether AIOSEO (free or pro) is active. |
| 676 |
* |
| 677 |
* @return bool |
| 678 |
*/ |
| 679 |
private function is_aioseo_active() { |
| 680 |
$this->ensure_plugin_api(); |
| 681 |
return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') |
| 682 |
|| is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php'); |
| 683 |
} |
| 684 |
|
| 685 |
/** |
| 686 |
* Ensure is_plugin_active() is loaded on the frontend. |
| 687 |
*/ |
| 688 |
private function ensure_plugin_api() { |
| 689 |
if (!function_exists('is_plugin_active')) { |
| 690 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 691 |
} |
| 692 |
} |
| 693 |
|
| 694 |
// ------------------------------------------------------------------ |
| 695 |
// Per-plugin sync |
| 696 |
// ------------------------------------------------------------------ |
| 697 |
|
| 698 |
/** |
| 699 |
* Mirror canonical data into Yoast post meta and indexable cache. |
| 700 |
* |
| 701 |
* @param int $post_id Post ID. |
| 702 |
* @param array $data Canonical key/value pairs. |
| 703 |
* @return bool True when at least one value-bearing field was mirrored, |
| 704 |
* false when the payload carried nothing to write. The caller |
| 705 |
* stamps `_metasync_plugin_sync_ts` from this, so it must |
| 706 |
* describe what was written and not merely that dispatch was |
| 707 |
* reached. |
| 708 |
* |
| 709 |
* Robots directives are excluded from that answer on purpose. |
| 710 |
* collect_post_data() synthesises noindex/nofollow on every |
| 711 |
* payload — absent any stored value they resolve to plain |
| 712 |
* index/follow — so they are written on every sync and say |
| 713 |
* nothing about whether a canonical value existed to mirror. |
| 714 |
* Counting them would make the result unconditionally true, |
| 715 |
* which is the bug this return contract exists to fix. |
| 716 |
*/ |
| 717 |
private function sync_yoast($post_id, array $data) { |
| 718 |
$wrote = false; |
| 719 |
|
| 720 |
// title |
| 721 |
if (!empty($data['title'])) { |
| 722 |
update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']); |
| 723 |
$wrote = true; |
| 724 |
} |
| 725 |
|
| 726 |
// description -- strip newlines first |
| 727 |
if (!empty($data['desc'])) { |
| 728 |
$desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']); |
| 729 |
update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc); |
| 730 |
$wrote = true; |
| 731 |
} |
| 732 |
|
| 733 |
// noindex: '0'=default, '1'=noindex, '2'=index |
| 734 |
if (array_key_exists('noindex', $data)) { |
| 735 |
$val = $data['noindex'] ? '1' : '2'; |
| 736 |
update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val); |
| 737 |
} |
| 738 |
|
| 739 |
// nofollow: '0'=follow, '1'=nofollow |
| 740 |
if (array_key_exists('nofollow', $data)) { |
| 741 |
update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0'); |
| 742 |
} |
| 743 |
|
| 744 |
// advanced robots: comma-separated NO spaces |
| 745 |
$adv = []; |
| 746 |
if (!empty($data['noarchive'])) { |
| 747 |
$adv[] = 'noarchive'; |
| 748 |
} |
| 749 |
if (!empty($data['nosnippet'])) { |
| 750 |
$adv[] = 'nosnippet'; |
| 751 |
} |
| 752 |
if (!empty($data['noimageindex'])) { |
| 753 |
$adv[] = 'noimageindex'; |
| 754 |
} |
| 755 |
// Written unconditionally so clearing the last directive clears the |
| 756 |
// field. Like the other robots writes it carries no canonical value, |
| 757 |
// so it does not make this a successful sync. |
| 758 |
update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv)); |
| 759 |
|
| 760 |
// OG |
| 761 |
if (!empty($data['og_title'])) { |
| 762 |
update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']); |
| 763 |
$wrote = true; |
| 764 |
} |
| 765 |
if (!empty($data['og_desc'])) { |
| 766 |
update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']); |
| 767 |
$wrote = true; |
| 768 |
} |
| 769 |
if (!empty($data['og_image'])) { |
| 770 |
update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])); |
| 771 |
$wrote = true; |
| 772 |
} |
| 773 |
|
| 774 |
// Twitter |
| 775 |
if (!empty($data['twitter_title'])) { |
| 776 |
update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']); |
| 777 |
$wrote = true; |
| 778 |
} |
| 779 |
if (!empty($data['twitter_desc'])) { |
| 780 |
update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']); |
| 781 |
$wrote = true; |
| 782 |
} |
| 783 |
|
| 784 |
// Canonical, focus keyword, breadcrumb |
| 785 |
if (!empty($data['canonical'])) { |
| 786 |
update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])); |
| 787 |
$wrote = true; |
| 788 |
} |
| 789 |
if (!empty($data['focus_keyword'])) { |
| 790 |
update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']); |
| 791 |
$wrote = true; |
| 792 |
} |
| 793 |
if (!empty($data['breadcrumb_title'])) { |
| 794 |
update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']); |
| 795 |
$wrote = true; |
| 796 |
} |
| 797 |
|
| 798 |
// Update wp_yoast_indexable cache row for immediate effect |
| 799 |
global $wpdb; |
| 800 |
$indexable_table = $wpdb->prefix . 'yoast_indexable'; |
| 801 |
|
| 802 |
$updates = []; |
| 803 |
if (!empty($data['title'])) { |
| 804 |
$updates['title'] = mb_substr($data['title'], 0, 191); |
| 805 |
} |
| 806 |
if (!empty($data['desc'])) { |
| 807 |
$updates['description'] = str_replace(["\n", "\r", "\t"], ' ', $data['desc']); |
| 808 |
} |
| 809 |
if (array_key_exists('noindex', $data)) { |
| 810 |
$updates['is_robots_noindex'] = $data['noindex'] ? 1 : 0; |
| 811 |
} |
| 812 |
if (array_key_exists('nofollow', $data)) { |
| 813 |
$updates['is_robots_nofollow'] = $data['nofollow'] ? 1 : 0; |
| 814 |
} |
| 815 |
if (array_key_exists('noarchive', $data)) { |
| 816 |
$updates['is_robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0; |
| 817 |
} |
| 818 |
if (array_key_exists('nosnippet', $data)) { |
| 819 |
$updates['is_robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0; |
| 820 |
} |
| 821 |
if (array_key_exists('noimageindex', $data)) { |
| 822 |
$updates['is_robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0; |
| 823 |
} |
| 824 |
if (!empty($data['og_title'])) { |
| 825 |
$updates['open_graph_title'] = mb_substr($data['og_title'], 0, 191); |
| 826 |
} |
| 827 |
if (!empty($data['og_image'])) { |
| 828 |
$updates['open_graph_image'] = $data['og_image']; |
| 829 |
} |
| 830 |
if (!empty($data['twitter_title'])) { |
| 831 |
$updates['twitter_title'] = mb_substr($data['twitter_title'], 0, 191); |
| 832 |
} |
| 833 |
if (!empty($data['twitter_card'])) { |
| 834 |
// twitter_card column only exists in newer Yoast versions; skip if absent |
| 835 |
$col_check = $wpdb->get_var($wpdb->prepare( |
| 836 |
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'twitter_card'", |
| 837 |
DB_NAME, |
| 838 |
$indexable_table |
| 839 |
)); |
| 840 |
if ($col_check) { |
| 841 |
$updates['twitter_card'] = $data['twitter_card']; |
| 842 |
} |
| 843 |
} |
| 844 |
if (!empty($data['canonical'])) { |
| 845 |
$updates['canonical'] = $data['canonical']; |
| 846 |
} |
| 847 |
if (!empty($data['focus_keyword'])) { |
| 848 |
$updates['primary_focus_keyword'] = mb_substr($data['focus_keyword'], 0, 191); |
| 849 |
} |
| 850 |
if (!empty($data['breadcrumb_title'])) { |
| 851 |
$updates['breadcrumb_title'] = mb_substr($data['breadcrumb_title'], 0, 191); |
| 852 |
} |
| 853 |
|
| 854 |
if (!empty($updates)) { |
| 855 |
$row_exists = $wpdb->get_var($wpdb->prepare( |
| 856 |
"SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'", |
| 857 |
$post_id |
| 858 |
)); |
| 859 |
|
| 860 |
if ($row_exists) { |
| 861 |
$wpdb->update( |
| 862 |
$indexable_table, |
| 863 |
$updates, |
| 864 |
['object_id' => $post_id, 'object_type' => 'post'] |
| 865 |
); |
| 866 |
} else { |
| 867 |
$post = get_post($post_id); |
| 868 |
$insert = array_merge([ |
| 869 |
'object_id' => $post_id, |
| 870 |
'object_type' => 'post', |
| 871 |
'object_sub_type' => $post ? $post->post_type : 'post', |
| 872 |
'post_status' => $post ? $post->post_status : 'publish', |
| 873 |
'author_id' => $post ? (int) $post->post_author : 0, |
| 874 |
'is_robots_noindex' => 0, |
| 875 |
'is_robots_nofollow' => 0, |
| 876 |
'is_robots_noarchive' => 0, |
| 877 |
'is_robots_nosnippet' => 0, |
| 878 |
'is_robots_noimageindex' => 0, |
| 879 |
'is_cornerstone' => 0, |
| 880 |
'created_at' => current_time('mysql'), |
| 881 |
'updated_at' => current_time('mysql'), |
| 882 |
], $updates); |
| 883 |
$wpdb->insert($indexable_table, $insert); |
| 884 |
} |
| 885 |
} |
| 886 |
|
| 887 |
return $wrote; |
| 888 |
} |
| 889 |
|
| 890 |
/** |
| 891 |
* Mirror canonical data into Rank Math post meta. |
| 892 |
* |
| 893 |
* @param int $post_id Post ID. |
| 894 |
* @param array $data Canonical key/value pairs. |
| 895 |
* @return bool True when at least one value-bearing field was mirrored, |
| 896 |
* false when the payload carried nothing to write. Robots |
| 897 |
* directives are excluded for the reason given on sync_yoast(). |
| 898 |
*/ |
| 899 |
private function sync_rankmath($post_id, array $data) { |
| 900 |
$wrote = false; |
| 901 |
|
| 902 |
// title, desc |
| 903 |
if (!empty($data['title'])) { |
| 904 |
update_post_meta($post_id, 'rank_math_title', $data['title']); |
| 905 |
$wrote = true; |
| 906 |
} |
| 907 |
if (!empty($data['desc'])) { |
| 908 |
update_post_meta($post_id, 'rank_math_description', $data['desc']); |
| 909 |
$wrote = true; |
| 910 |
} |
| 911 |
|
| 912 |
// robots: PHP indexed array |
| 913 |
if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) { |
| 914 |
$existing = get_post_meta($post_id, 'rank_math_robots', true); |
| 915 |
$robots = is_array($existing) ? $existing : []; |
| 916 |
$robots = array_values(array_diff($robots, ['index', 'noindex', 'follow', 'nofollow'])); |
| 917 |
if (array_key_exists('noindex', $data)) { |
| 918 |
$robots[] = $data['noindex'] ? 'noindex' : 'index'; |
| 919 |
} |
| 920 |
if (array_key_exists('nofollow', $data)) { |
| 921 |
$robots[] = $data['nofollow'] ? 'nofollow' : 'follow'; |
| 922 |
} |
| 923 |
update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots))); |
| 924 |
} |
| 925 |
|
| 926 |
// Advanced robots: max-* go into rank_math_advanced_robots |
| 927 |
$adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview']; |
| 928 |
$has_adv = false; |
| 929 |
foreach ($adv_keys as $k) { |
| 930 |
if (array_key_exists($k, $data)) { |
| 931 |
$has_adv = true; |
| 932 |
break; |
| 933 |
} |
| 934 |
} |
| 935 |
if ($has_adv) { |
| 936 |
$existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true); |
| 937 |
$adv = is_array($existing_adv) ? $existing_adv : []; |
| 938 |
if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) { |
| 939 |
$val = (int) $data['max_snippet']; |
| 940 |
$adv['max-snippet'] = (string) $val; |
| 941 |
} |
| 942 |
if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) { |
| 943 |
$allowed = ['none', 'standard', 'large']; |
| 944 |
if (in_array($data['max_image_preview'], $allowed, true)) { |
| 945 |
$adv['max-image-preview'] = (string) $data['max_image_preview']; |
| 946 |
} |
| 947 |
} |
| 948 |
if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) { |
| 949 |
$val = (int) $data['max_video_preview']; |
| 950 |
$adv['max-video-preview'] = (string) $val; |
| 951 |
} |
| 952 |
if (!empty($adv)) { |
| 953 |
update_post_meta($post_id, 'rank_math_advanced_robots', $adv); |
| 954 |
} |
| 955 |
} |
| 956 |
|
| 957 |
// Sync noarchive/nosnippet/noimageindex into rank_math_robots |
| 958 |
if (array_key_exists('noarchive', $data) || array_key_exists('nosnippet', $data) || array_key_exists('noimageindex', $data)) { |
| 959 |
$existing = get_post_meta($post_id, 'rank_math_robots', true); |
| 960 |
$robots = is_array($existing) ? $existing : []; |
| 961 |
foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) { |
| 962 |
if (!array_key_exists($dir, $data)) { |
| 963 |
continue; |
| 964 |
} |
| 965 |
$robots = array_values(array_diff($robots, [$dir])); |
| 966 |
if (!empty($data[$dir])) { |
| 967 |
$robots[] = $dir; |
| 968 |
} |
| 969 |
} |
| 970 |
update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots))); |
| 971 |
} |
| 972 |
|
| 973 |
// OG |
| 974 |
if (!empty($data['og_title'])) { |
| 975 |
update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']); |
| 976 |
$wrote = true; |
| 977 |
} |
| 978 |
if (!empty($data['og_desc'])) { |
| 979 |
update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']); |
| 980 |
$wrote = true; |
| 981 |
} |
| 982 |
if (!empty($data['og_image'])) { |
| 983 |
update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])); |
| 984 |
$img_id = attachment_url_to_postid($data['og_image']); |
| 985 |
if ($img_id) { |
| 986 |
update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id); |
| 987 |
} |
| 988 |
$wrote = true; |
| 989 |
} |
| 990 |
|
| 991 |
// Twitter |
| 992 |
if (!empty($data['twitter_title'])) { |
| 993 |
update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']); |
| 994 |
$wrote = true; |
| 995 |
} |
| 996 |
if (!empty($data['twitter_desc'])) { |
| 997 |
update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']); |
| 998 |
$wrote = true; |
| 999 |
} |
| 1000 |
if (!empty($data['twitter_card'])) { |
| 1001 |
$valid_cards = ['summary', 'summary_large_image', 'app', 'player']; |
| 1002 |
if (in_array($data['twitter_card'], $valid_cards, true)) { |
| 1003 |
update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']); |
| 1004 |
$wrote = true; |
| 1005 |
} |
| 1006 |
} |
| 1007 |
|
| 1008 |
// Canonical, focus keyword, breadcrumb |
| 1009 |
if (!empty($data['canonical'])) { |
| 1010 |
update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])); |
| 1011 |
$wrote = true; |
| 1012 |
} |
| 1013 |
if (!empty($data['focus_keyword'])) { |
| 1014 |
update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']); |
| 1015 |
$wrote = true; |
| 1016 |
} |
| 1017 |
if (!empty($data['breadcrumb_title'])) { |
| 1018 |
update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']); |
| 1019 |
$wrote = true; |
| 1020 |
} |
| 1021 |
|
| 1022 |
return $wrote; |
| 1023 |
} |
| 1024 |
|
| 1025 |
/** |
| 1026 |
* Mirror canonical data into the AIOSEO wp_aioseo_posts custom table. |
| 1027 |
* |
| 1028 |
* @param int $post_id Post ID. |
| 1029 |
* @param array $data Canonical key/value pairs. |
| 1030 |
* @return bool True when at least one value-bearing field was written, |
| 1031 |
* false when the table is missing, the write failed, or the |
| 1032 |
* payload carried only robots directives. Robots are excluded |
| 1033 |
* for the reason given on sync_yoast(), so the receipt means |
| 1034 |
* the same thing for all three plugins. |
| 1035 |
*/ |
| 1036 |
private function sync_aioseo($post_id, array $data) { |
| 1037 |
global $wpdb; |
| 1038 |
|
| 1039 |
$table = $wpdb->prefix . 'aioseo_posts'; |
| 1040 |
|
| 1041 |
// Bail if the AIOSEO post table does not exist (plugin not initialised). |
| 1042 |
$table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); |
| 1043 |
if ($table_exists !== $table) { |
| 1044 |
return false; |
| 1045 |
} |
| 1046 |
|
| 1047 |
$row = []; |
| 1048 |
// Robots fill $row too, so a separate flag is needed: the row is still |
| 1049 |
// written for a robots-only payload, it just is not a content sync. |
| 1050 |
$wrote = false; |
| 1051 |
|
| 1052 |
if (!empty($data['title'])) { |
| 1053 |
$row['title'] = sanitize_text_field($data['title']); |
| 1054 |
$wrote = true; |
| 1055 |
} |
| 1056 |
if (!empty($data['desc'])) { |
| 1057 |
$row['description'] = sanitize_text_field($data['desc']); |
| 1058 |
$wrote = true; |
| 1059 |
} |
| 1060 |
if (!empty($data['og_title'])) { |
| 1061 |
$row['og_title'] = sanitize_text_field($data['og_title']); |
| 1062 |
$wrote = true; |
| 1063 |
} |
| 1064 |
if (!empty($data['og_desc'])) { |
| 1065 |
$row['og_description'] = sanitize_text_field($data['og_desc']); |
| 1066 |
$wrote = true; |
| 1067 |
} |
| 1068 |
if (!empty($data['og_image'])) { |
| 1069 |
$row['og_image_type'] = 'custom'; |
| 1070 |
$row['og_image_custom_url'] = esc_url_raw($data['og_image']); |
| 1071 |
$wrote = true; |
| 1072 |
} |
| 1073 |
if (!empty($data['twitter_title'])) { |
| 1074 |
$row['twitter_title'] = sanitize_text_field($data['twitter_title']); |
| 1075 |
$wrote = true; |
| 1076 |
} |
| 1077 |
if (!empty($data['twitter_desc'])) { |
| 1078 |
$row['twitter_description'] = sanitize_text_field($data['twitter_desc']); |
| 1079 |
$wrote = true; |
| 1080 |
} |
| 1081 |
if (!empty($data['twitter_card'])) { |
| 1082 |
$valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app']; |
| 1083 |
if (in_array($data['twitter_card'], $valid_cards, true)) { |
| 1084 |
$row['twitter_card'] = $data['twitter_card']; |
| 1085 |
$wrote = true; |
| 1086 |
} |
| 1087 |
} |
| 1088 |
if (!empty($data['canonical'])) { |
| 1089 |
$row['canonical_url'] = esc_url_raw($data['canonical']); |
| 1090 |
$wrote = true; |
| 1091 |
} |
| 1092 |
|
| 1093 |
// focus keyword as keyphrases JSON |
| 1094 |
if (!empty($data['focus_keyword'])) { |
| 1095 |
$row['keyphrases'] = wp_json_encode([ |
| 1096 |
'focus' => [ |
| 1097 |
'keyphrase' => sanitize_text_field($data['focus_keyword']), |
| 1098 |
'score' => 0, |
| 1099 |
'analysis' => new \stdClass(), |
| 1100 |
], |
| 1101 |
'additional' => [], |
| 1102 |
]); |
| 1103 |
$wrote = true; |
| 1104 |
} |
| 1105 |
|
| 1106 |
// Robots |
| 1107 |
$has_robots = false; |
| 1108 |
foreach (['noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'] as $k) { |
| 1109 |
if (array_key_exists($k, $data)) { |
| 1110 |
$has_robots = true; |
| 1111 |
break; |
| 1112 |
} |
| 1113 |
} |
| 1114 |
if ($has_robots) { |
| 1115 |
$row['robots_default'] = 0; |
| 1116 |
if (array_key_exists('noindex', $data)) { |
| 1117 |
$row['robots_noindex'] = $data['noindex'] ? 1 : 0; |
| 1118 |
} |
| 1119 |
if (array_key_exists('nofollow', $data)) { |
| 1120 |
$row['robots_nofollow'] = $data['nofollow'] ? 1 : 0; |
| 1121 |
} |
| 1122 |
if (array_key_exists('noarchive', $data)) { |
| 1123 |
$row['robots_noarchive'] = !empty($data['noarchive']) ? 1 : 0; |
| 1124 |
} |
| 1125 |
if (array_key_exists('nosnippet', $data)) { |
| 1126 |
$row['robots_nosnippet'] = !empty($data['nosnippet']) ? 1 : 0; |
| 1127 |
} |
| 1128 |
if (array_key_exists('noimageindex', $data)) { |
| 1129 |
$row['robots_noimageindex'] = !empty($data['noimageindex']) ? 1 : 0; |
| 1130 |
} |
| 1131 |
if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) { |
| 1132 |
$row['robots_max_snippet'] = (int) $data['max_snippet']; |
| 1133 |
} |
| 1134 |
if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) { |
| 1135 |
$row['robots_max_videopreview'] = (int) $data['max_video_preview']; |
| 1136 |
} |
| 1137 |
if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) { |
| 1138 |
$allowed = ['none', 'standard', 'large']; |
| 1139 |
if (in_array($data['max_image_preview'], $allowed, true)) { |
| 1140 |
$row['robots_max_imagepreview'] = $data['max_image_preview']; |
| 1141 |
} |
| 1142 |
} |
| 1143 |
} |
| 1144 |
|
| 1145 |
if (empty($row)) { |
| 1146 |
return false; |
| 1147 |
} |
| 1148 |
|
| 1149 |
$row['updated'] = current_time('mysql'); |
| 1150 |
|
| 1151 |
$existing_id = $wpdb->get_var($wpdb->prepare( |
| 1152 |
"SELECT id FROM {$table} WHERE post_id = %d", |
| 1153 |
$post_id |
| 1154 |
)); |
| 1155 |
|
| 1156 |
if ($existing_id) { |
| 1157 |
return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote; |
| 1158 |
} |
| 1159 |
|
| 1160 |
// New row -- must include all NOT NULL columns with no defaults |
| 1161 |
$row['post_id'] = $post_id; |
| 1162 |
$row['created'] = current_time('mysql'); |
| 1163 |
$robot_defaults = [ |
| 1164 |
'robots_default' => isset($row['robots_noindex']) ? 0 : 1, |
| 1165 |
'robots_noindex' => 0, |
| 1166 |
'robots_nofollow' => 0, |
| 1167 |
'robots_noarchive' => 0, |
| 1168 |
'robots_nosnippet' => 0, |
| 1169 |
'robots_noimageindex' => 0, |
| 1170 |
'robots_noodp' => 0, |
| 1171 |
'robots_notranslate' => 0, |
| 1172 |
]; |
| 1173 |
$row = array_merge($robot_defaults, $row); |
| 1174 |
|
| 1175 |
return ($wpdb->insert($table, $row) !== false) && $wrote; |
| 1176 |
} |
| 1177 |
|
| 1178 |
// ------------------------------------------------------------------ |
| 1179 |
// Two-way sync: sidebar JSON ↔ legacy meta boxes |
| 1180 |
// ------------------------------------------------------------------ |
| 1181 |
|
| 1182 |
/** |
| 1183 |
* Mirror _metasync_robots_advanced JSON → legacy meta box keys. |
| 1184 |
* |
| 1185 |
* Called when the sidebar writes the JSON key so the classic-editor |
| 1186 |
* meta boxes reflect the same values. |
| 1187 |
* |
| 1188 |
* @param int $post_id Post ID. |
| 1189 |
* @param string $json_value Raw JSON string from _metasync_robots_advanced. |
| 1190 |
*/ |
| 1191 |
private function sync_to_legacy_meta($post_id, $json_value) { |
| 1192 |
static $syncing_legacy = []; |
| 1193 |
if (!empty($syncing_legacy[$post_id])) { |
| 1194 |
return; |
| 1195 |
} |
| 1196 |
// Our own sync_legacy_to_json() write triggered this. The legacy meta |
| 1197 |
// box values are the source of truth in that direction, and mirroring |
| 1198 |
// back would drop any value this function treats as a default (-1 / |
| 1199 |
// large), so bail out and leave the legacy keys alone. |
| 1200 |
if (!empty($this->syncing_legacy_to_json[$post_id])) { |
| 1201 |
return; |
| 1202 |
} |
| 1203 |
$syncing_legacy[$post_id] = true; |
| 1204 |
// Block sync_legacy_to_json from running while we write legacy keys |
| 1205 |
$this->syncing_json_to_legacy[$post_id] = true; |
| 1206 |
|
| 1207 |
try { |
| 1208 |
$robots = is_string($json_value) ? json_decode($json_value, true) : $json_value; |
| 1209 |
if (!is_array($robots)) { |
| 1210 |
return; |
| 1211 |
} |
| 1212 |
|
| 1213 |
// Build metasync_common_robots array |
| 1214 |
$common = get_post_meta($post_id, 'metasync_common_robots', true); |
| 1215 |
if (!is_array($common)) { |
| 1216 |
$common = []; |
| 1217 |
} |
| 1218 |
foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) { |
| 1219 |
if (!empty($robots[$dir])) { |
| 1220 |
$common[$dir] = $dir; |
| 1221 |
} else { |
| 1222 |
unset($common[$dir]); |
| 1223 |
} |
| 1224 |
} |
| 1225 |
if (!empty($common)) { |
| 1226 |
update_post_meta($post_id, 'metasync_common_robots', $common); |
| 1227 |
} else { |
| 1228 |
delete_post_meta($post_id, 'metasync_common_robots'); |
| 1229 |
} |
| 1230 |
|
| 1231 |
// Build metasync_advance_robots array — skip default values to keep legacy clean |
| 1232 |
$adv = []; |
| 1233 |
if (isset($robots['max_snippet']) && $robots['max_snippet'] !== null && (int) $robots['max_snippet'] !== -1) { |
| 1234 |
$adv['max-snippet'] = ['enable' => '1', 'length' => (string) $robots['max_snippet']]; |
| 1235 |
} |
| 1236 |
if (isset($robots['max_image_preview']) && $robots['max_image_preview'] !== null && $robots['max_image_preview'] !== 'large') { |
| 1237 |
$adv['max-image-preview'] = ['enable' => '1', 'length' => (string) $robots['max_image_preview']]; |
| 1238 |
} |
| 1239 |
if (isset($robots['max_video_preview']) && $robots['max_video_preview'] !== null && (int) $robots['max_video_preview'] !== -1) { |
| 1240 |
$adv['max-video-preview'] = ['enable' => '1', 'length' => (string) $robots['max_video_preview']]; |
| 1241 |
} |
| 1242 |
if (!empty($adv)) { |
| 1243 |
update_post_meta($post_id, 'metasync_advance_robots', $adv); |
| 1244 |
} else { |
| 1245 |
delete_post_meta($post_id, 'metasync_advance_robots'); |
| 1246 |
} |
| 1247 |
} finally { |
| 1248 |
unset($syncing_legacy[$post_id]); |
| 1249 |
unset($this->syncing_json_to_legacy[$post_id]); |
| 1250 |
} |
| 1251 |
} |
| 1252 |
|
| 1253 |
/** |
| 1254 |
* Rebuild _metasync_robots_advanced JSON from legacy meta box keys. |
| 1255 |
* |
| 1256 |
* Called when the classic-editor meta boxes save metasync_common_robots |
| 1257 |
* or metasync_advance_robots so the sidebar JSON stays in sync. |
| 1258 |
* |
| 1259 |
* @param int $post_id Post ID. |
| 1260 |
*/ |
| 1261 |
private function sync_legacy_to_json($post_id) { |
| 1262 |
static $syncing_json = []; |
| 1263 |
// If sync_to_legacy_meta is currently running, skip — it already wrote the correct JSON |
| 1264 |
if (!empty($this->syncing_json_to_legacy[$post_id])) { |
| 1265 |
return; |
| 1266 |
} |
| 1267 |
if (!empty($syncing_json[$post_id])) { |
| 1268 |
return; |
| 1269 |
} |
| 1270 |
$syncing_json[$post_id] = true; |
| 1271 |
// Block sync_to_legacy_meta from reacting to the JSON write below |
| 1272 |
$this->syncing_legacy_to_json[$post_id] = true; |
| 1273 |
|
| 1274 |
try { |
| 1275 |
$common = get_post_meta($post_id, 'metasync_common_robots', true); |
| 1276 |
if (!is_array($common)) { |
| 1277 |
$common = []; |
| 1278 |
} |
| 1279 |
$adv = get_post_meta($post_id, 'metasync_advance_robots', true); |
| 1280 |
if (!is_array($adv)) { |
| 1281 |
$adv = []; |
| 1282 |
} |
| 1283 |
|
| 1284 |
$json = []; |
| 1285 |
|
| 1286 |
// Boolean directives from common_robots |
| 1287 |
foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) { |
| 1288 |
$json[$dir] = !empty($common[$dir]); |
| 1289 |
} |
| 1290 |
|
| 1291 |
// max-* directives from advance_robots |
| 1292 |
if (!empty($adv['max-snippet']['enable'])) { |
| 1293 |
$json['max_snippet'] = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1; |
| 1294 |
} |
| 1295 |
if (!empty($adv['max-image-preview']['enable'])) { |
| 1296 |
$json['max_image_preview'] = isset($adv['max-image-preview']['length']) ? (string) $adv['max-image-preview']['length'] : 'large'; |
| 1297 |
} |
| 1298 |
if (!empty($adv['max-video-preview']['enable'])) { |
| 1299 |
$json['max_video_preview'] = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1; |
| 1300 |
} |
| 1301 |
|
| 1302 |
// Only write if there's something meaningful |
| 1303 |
$has_value = false; |
| 1304 |
foreach ($json as $v) { |
| 1305 |
if ($v !== false && $v !== null) { |
| 1306 |
$has_value = true; |
| 1307 |
break; |
| 1308 |
} |
| 1309 |
} |
| 1310 |
|
| 1311 |
if ($has_value) { |
| 1312 |
update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($json)); |
| 1313 |
} else { |
| 1314 |
delete_post_meta($post_id, '_metasync_robots_advanced'); |
| 1315 |
} |
| 1316 |
} finally { |
| 1317 |
unset($syncing_json[$post_id]); |
| 1318 |
unset($this->syncing_legacy_to_json[$post_id]); |
| 1319 |
} |
| 1320 |
} |
| 1321 |
} |
| 1322 |
|