PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
2.7.1 2.7.2 2.7.0 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 All 141 releases
metasync / otto / metasync-otto-seo-functions.php

metasync-otto-seo-functions.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.22, at otto/metasync-otto-seo-functions.php

3,226 lines 139.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OTTO SEO Integration Functions
4 *
5 * This file contains all the supporting functions for OTTO SEO data processing
6 * and SEO plugin integration.
7 *
8 * @package MetaSync
9 * @since 1.0.0
10 */
11
12 // Prevent direct access
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * Fetch SEO data from OTTO API
19 *
20 * @param string $route The URL route
21 * @param string $uuid The OTTO UUID
22 * @return array|false SEO data array or false on failure
23 */
24 function metasync_fetch_otto_seo_data($route, $uuid) {
25 $route = esc_url_raw($route);
26
27 # Get OTTO API endpoint (use endpoint manager if available)
28 $api_endpoint = 'https://sa.searchatlas.com/api/v2/otto-url-details'; # default
29 if (class_exists('Metasync_Endpoint_Manager')) {
30 $api_endpoint = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
31 }
32
33 # Construct OTTO API endpoint URL
34 $api_url = add_query_arg(
35 array(
36 'url' => $route,
37 'uuid' => $uuid
38 ),
39 $api_endpoint
40 );
41
42 # Make API request with timeout and error handling
43 # Timeout is intentionally short: this function runs from a background job scheduled
44 # by metasync_otto_crawl_notify(), and a slow upstream response should not stall the
45 # job runner or pile up failures.
46 $response = wp_remote_get($api_url, array(
47 'timeout' => 5,
48 'sslverify' => true,
49 'headers' => array(
50 'User-Agent' => 'MetaSync-WordPress-Plugin/1.0'
51 )
52 ));
53
54 # Check for HTTP errors
55 if (is_wp_error($response)) {
56 return false;
57 }
58
59 # Check response code
60 $response_code = wp_remote_retrieve_response_code($response);
61
62 if ($response_code === 429) {
63 metasync_record_otto_rate_limit_hit();
64 }
65
66 if ($response_code !== 200) {
67 return false;
68 }
69
70 # Parse response body
71 $body = wp_remote_retrieve_body($response);
72 if (empty($body)) {
73 return false;
74 }
75
76 $data = json_decode($body, true);
77 if (json_last_error() !== JSON_ERROR_NONE) {
78 return false;
79 }
80
81 return $data;
82 }
83
84 /**
85 * Record a SearchAtlas OTTO_URL_DETAILS API rate limit hit (HTTP 429)
86 *
87 * Appends the current Unix timestamp to a rolling log stored in a WordPress option.
88 * Entries older than 48 hours are pruned on every write to prevent unbounded growth.
89 * The log is read by the Site Health rate limit check to surface frequent throttling
90 * to site administrators.
91 *
92 * @since 1.0.0
93 */
94 function metasync_record_otto_rate_limit_hit() {
95 $log = get_option('metasync_otto_rate_limit_log', []);
96 $cutoff = time() - (48 * HOUR_IN_SECONDS);
97 $log = array_values(array_filter($log, function($t) use ($cutoff) { return $t > $cutoff; }));
98 $log[] = time();
99 update_option('metasync_otto_rate_limit_log', $log, false);
100 }
101
102 /**
103 * Record a failed MetaSync cron action
104 *
105 * Appends the current Unix timestamp to a rolling log stored in a WordPress option.
106 * Entries older than 48 hours are pruned on every write to prevent unbounded growth.
107 * The log is read by the Site Health failed actions check.
108 *
109 * @since 1.0.0
110 * @param string $hook The cron hook name that failed (optional, for context)
111 */
112 function metasync_record_failed_action( $hook = '' ) {
113 $log = get_option( 'metasync_failed_actions_log', [] );
114 $cutoff = time() - ( 48 * HOUR_IN_SECONDS );
115 $log = array_values( array_filter( $log, function( $t ) use ( $cutoff ) { return $t > $cutoff; } ) );
116 $log[] = time();
117 update_option( 'metasync_failed_actions_log', $log, false );
118 }
119
120 /**
121 * Clean SEO plugin template variables from text
122 * Removes variables like %%title%%, %%sep%%, %%sitename%%, etc.
123 *
124 * @param string $text The text to clean
125 * @return string Cleaned text
126 */
127 function metasync_clean_seo_variables($text) {
128 if (empty($text)) {
129 return $text;
130 }
131
132 # Remove Yoast/RankMath template variables (%%variable%%)
133 $text = preg_replace('/%%[^%]+%%/i', '', $text);
134
135 # SEO text values (og/meta/twitter descriptions and titles) must never
136 # contain page-builder shortcodes. OTTO can derive these from raw page content,
137 # so strip registered shortcodes plus any leftover shortcode-style tags (e.g.
138 # unregistered Divi [et_pb_*]). The pattern is anchored to a leading letter so
139 # legitimate bracketed prose like "[2026 Guide]" is preserved.
140 $text = strip_shortcodes($text);
141 $text = preg_replace('/\[\/?[a-zA-Z][^\]]*\]/', '', $text);
142
143 # Remove multiple spaces created by removal
144 $text = preg_replace('/\s+/', ' ', $text);
145
146 # Remove leading/trailing separators and spaces
147 $text = trim($text, ' |-–—');
148
149 # Clean up any remaining double separators
150 $text = preg_replace('/\s*[-|–—]\s*[-|–—]\s*/', ' - ', $text);
151
152 return trim($text);
153 }
154
155 /**
156 * Extract meta title from OTTO response data
157 *
158 * @param array $seo_data The SEO data from OTTO API
159 * @return string|null The meta title or null if not found
160 */
161 function metasync_extract_meta_title($seo_data) {
162 # First check header replacements for title
163 if (!empty($seo_data['header_replacements']) && is_array($seo_data['header_replacements'])) {
164 foreach ($seo_data['header_replacements'] as $replacement) {
165 if (isset($replacement['type']) && $replacement['type'] === 'title'
166 && !empty($replacement['recommended_value'])) {
167 $title = sanitize_text_field($replacement['recommended_value']);
168 # Clean SEO plugin variables
169 $title = metasync_clean_seo_variables($title);
170 # Only return if we have actual content after cleaning
171 return !empty($title) ? $title : null;
172 }
173 }
174 }
175
176 # Check header_html_insertion for title tag
177 if (!empty($seo_data['header_html_insertion'])) {
178 $html_content = $seo_data['header_html_insertion'];
179
180 # Look for title tag with data-otto-pixel attribute
181 if (preg_match('/<title[^>]*data-otto-pixel=["\']dynamic-seo["\'][^>]*>([^<]+)<\/title>/i', $html_content, $matches)) {
182 $title = sanitize_text_field(trim($matches[1]));
183 # Clean SEO plugin variables
184 $title = metasync_clean_seo_variables($title);
185 # Only return if we have actual content after cleaning
186 return !empty($title) ? $title : null;
187 }
188
189 # Fallback: look for any title tag
190 if (preg_match('/<title[^>]*>([^<]+)<\/title>/i', $html_content, $matches)) {
191 $title = sanitize_text_field(trim($matches[1]));
192 # Clean SEO plugin variables
193 $title = metasync_clean_seo_variables($title);
194 # Only return if we have actual content after cleaning
195 return !empty($title) ? $title : null;
196 }
197 }
198
199 return null;
200 }
201
202 /**
203 * Extract meta description from OTTO response data
204 *
205 * @param array $seo_data The SEO data from OTTO API
206 * @return string|null The meta description or null if not found
207 */
208 function metasync_extract_meta_description($seo_data) {
209 # First check header replacements for meta description
210 if (!empty($seo_data['header_replacements']) && is_array($seo_data['header_replacements'])) {
211 foreach ($seo_data['header_replacements'] as $replacement) {
212 $is_meta_type = isset($replacement['type']) && $replacement['type'] === 'meta';
213 $has_description_name = isset($replacement['name']) && $replacement['name'] === 'description';
214 $has_og_description = isset($replacement['property']) && $replacement['property'] === 'og:description';
215 $has_value = !empty($replacement['recommended_value']);
216
217 if ($is_meta_type && ($has_description_name || $has_og_description) && $has_value) {
218 $description = sanitize_textarea_field($replacement['recommended_value']);
219
220 # Clean SEO plugin variables
221 $description = metasync_clean_seo_variables($description);
222
223 # Only return if we have actual content after cleaning
224 if (!empty($description)) {
225 return $description;
226 }
227 }
228 }
229 }
230
231 # Check header_html_insertion for meta description tag
232 if (!empty($seo_data['header_html_insertion'])) {
233 $html_content = $seo_data['header_html_insertion'];
234
235 # Look for meta description tag with data-otto-pixel attribute
236 if (preg_match('/<meta\s+name=["\']description["\']\s+[^>]*content="([^"]+)"[^>]*>/i', $html_content, $matches)
237 || preg_match('/<meta\s+name=["\']description["\']\s+[^>]*content=\'([^\']+)\'[^>]*>/i', $html_content, $matches)) {
238 $description = sanitize_textarea_field($matches[1]);
239 # Clean SEO plugin variables
240 $description = metasync_clean_seo_variables($description);
241
242 # Only return if we have actual content after cleaning
243 if (!empty($description)) {
244 return $description;
245 }
246 }
247 }
248
249 return null;
250 }
251
252 /**
253 * Extract meta keywords from OTTO response data
254 *
255 * @param array $seo_data The SEO data from OTTO API
256 * @return string|null The meta keywords or null if not found
257 */
258 function metasync_extract_meta_keywords($seo_data) {
259 # Check header_html_insertion for meta keywords tag
260 if (!empty($seo_data['header_html_insertion'])) {
261 $html_content = $seo_data['header_html_insertion'];
262
263 # Look for meta keywords tag with data-otto-pixel attribute
264 if (preg_match('/<meta\s+name=["\']keywords["\']\s+[^>]*content="([^"]+)"[^>]*>/i', $html_content, $matches)
265 || preg_match('/<meta\s+name=["\']keywords["\']\s+[^>]*content=\'([^\']+)\'[^>]*>/i', $html_content, $matches)) {
266 return sanitize_text_field($matches[1]);
267 }
268 }
269
270 return null;
271 }
272
273 /**
274 * Extract Open Graph title from OTTO response data
275 *
276 * @param array $seo_data The SEO data from OTTO API
277 * @return string|null The OG title or null if not found
278 */
279 function metasync_extract_og_title($seo_data) {
280 # Check header replacements for OG title
281 if (!empty($seo_data['header_replacements']) && is_array($seo_data['header_replacements'])) {
282 foreach ($seo_data['header_replacements'] as $replacement) {
283 if (isset($replacement['type']) && $replacement['type'] === 'meta'
284 && isset($replacement['property']) && $replacement['property'] === 'og:title'
285 && !empty($replacement['recommended_value'])) {
286 $og_title = sanitize_text_field($replacement['recommended_value']);
287 # Clean SEO plugin variables
288 $og_title = metasync_clean_seo_variables($og_title);
289 # Only return if we have actual content after cleaning
290 return !empty($og_title) ? $og_title : null;
291 }
292 }
293 }
294
295 # Fallback: Check header_html_insertion for og:title meta tag
296 if (!empty($seo_data['header_html_insertion'])) {
297 $html_content = $seo_data['header_html_insertion'];
298
299 # Match <meta property="og:title" content="..."> (property and content in any order)
300 if (preg_match('/<meta\s+[^>]*property=["\']og:title["\'][^>]*content="([^"]+)"[^>]*>/i', $html_content, $matches)
301 || preg_match('/<meta\s+[^>]*property=["\']og:title["\'][^>]*content=\'([^\']+)\'[^>]*>/i', $html_content, $matches)
302 || preg_match('/<meta\s+[^>]*content="([^"]+)"[^>]*property=["\']og:title["\'][^>]*>/i', $html_content, $matches)
303 || preg_match('/<meta\s+[^>]*content=\'([^\']+)\'[^>]*property=["\']og:title["\'][^>]*>/i', $html_content, $matches)) {
304 $og_title = sanitize_text_field(trim($matches[1]));
305 $og_title = metasync_clean_seo_variables($og_title);
306 return !empty($og_title) ? $og_title : null;
307 }
308 }
309
310 return null;
311 }
312
313 /**
314 * Extract Open Graph description from OTTO response data
315 *
316 * @param array $seo_data The SEO data from OTTO API
317 * @return string|null The OG description or null if not found
318 */
319 function metasync_extract_og_description($seo_data) {
320 # Check header replacements for OG description
321 if (!empty($seo_data['header_replacements']) && is_array($seo_data['header_replacements'])) {
322 foreach ($seo_data['header_replacements'] as $replacement) {
323 if (isset($replacement['type']) && $replacement['type'] === 'meta'
324 && isset($replacement['property']) && $replacement['property'] === 'og:description'
325 && !empty($replacement['recommended_value'])) {
326 $og_description = sanitize_textarea_field($replacement['recommended_value']);
327 # Clean SEO plugin variables
328 $og_description = metasync_clean_seo_variables($og_description);
329 # Only return if we have actual content after cleaning
330 return !empty($og_description) ? $og_description : null;
331 }
332 }
333 }
334
335 # Fallback: Check header_html_insertion for og:description meta tag
336 if (!empty($seo_data['header_html_insertion'])) {
337 $html_content = $seo_data['header_html_insertion'];
338
339 # Match <meta property="og:description" content="..."> (property and content in any order)
340 if (preg_match('/<meta\s+[^>]*property=["\']og:description["\'][^>]*content="([^"]+)"[^>]*>/i', $html_content, $matches)
341 || preg_match('/<meta\s+[^>]*property=["\']og:description["\'][^>]*content=\'([^\']+)\'[^>]*>/i', $html_content, $matches)
342 || preg_match('/<meta\s+[^>]*content="([^"]+)"[^>]*property=["\']og:description["\'][^>]*>/i', $html_content, $matches)
343 || preg_match('/<meta\s+[^>]*content=\'([^\']+)\'[^>]*property=["\']og:description["\'][^>]*>/i', $html_content, $matches)) {
344 $og_description = sanitize_textarea_field(trim($matches[1]));
345 $og_description = metasync_clean_seo_variables($og_description);
346 return !empty($og_description) ? $og_description : null;
347 }
348 }
349
350 return null;
351 }
352
353 /**
354 * Extract Twitter title from OTTO response data
355 *
356 * @param array $seo_data The SEO data from OTTO API
357 * @return string|null The Twitter title or null if not found
358 */
359 function metasync_extract_twitter_title($seo_data) {
360 # Check header replacements for Twitter title
361 if (!empty($seo_data['header_replacements']) && is_array($seo_data['header_replacements'])) {
362 foreach ($seo_data['header_replacements'] as $replacement) {
363 if (isset($replacement['type']) && $replacement['type'] === 'meta'
364 && isset($replacement['name']) && $replacement['name'] === 'twitter:title'
365 && !empty($replacement['recommended_value'])) {
366 $twitter_title = sanitize_text_field($replacement['recommended_value']);
367 # Clean SEO plugin variables
368 $twitter_title = metasync_clean_seo_variables($twitter_title);
369 # Only return if we have actual content after cleaning
370 return !empty($twitter_title) ? $twitter_title : null;
371 }
372 }
373 }
374
375 # Fallback: Check header_html_insertion for twitter:title meta tag
376 if (!empty($seo_data['header_html_insertion'])) {
377 $html_content = $seo_data['header_html_insertion'];
378
379 # Match <meta name="twitter:title" content="..."> (name and content in any order)
380 if (preg_match('/<meta\s+[^>]*name=["\']twitter:title["\'][^>]*content="([^"]+)"[^>]*>/i', $html_content, $matches)
381 || preg_match('/<meta\s+[^>]*name=["\']twitter:title["\'][^>]*content=\'([^\']+)\'[^>]*>/i', $html_content, $matches)
382 || preg_match('/<meta\s+[^>]*content="([^"]+)"[^>]*name=["\']twitter:title["\'][^>]*>/i', $html_content, $matches)
383 || preg_match('/<meta\s+[^>]*content=\'([^\']+)\'[^>]*name=["\']twitter:title["\'][^>]*>/i', $html_content, $matches)) {
384 $twitter_title = sanitize_text_field(trim($matches[1]));
385 $twitter_title = metasync_clean_seo_variables($twitter_title);
386 return !empty($twitter_title) ? $twitter_title : null;
387 }
388 }
389
390 return null;
391 }
392
393 /**
394 * Extract Twitter description from OTTO response data
395 *
396 * @param array $seo_data The SEO data from OTTO API
397 * @return string|null The Twitter description or null if not found
398 */
399 function metasync_extract_twitter_description($seo_data) {
400 # Check header replacements for Twitter description
401 if (!empty($seo_data['header_replacements']) && is_array($seo_data['header_replacements'])) {
402 foreach ($seo_data['header_replacements'] as $replacement) {
403 if (isset($replacement['type']) && $replacement['type'] === 'meta'
404 && isset($replacement['name']) && $replacement['name'] === 'twitter:description'
405 && !empty($replacement['recommended_value'])) {
406 $twitter_description = sanitize_textarea_field($replacement['recommended_value']);
407 # Clean SEO plugin variables
408 $twitter_description = metasync_clean_seo_variables($twitter_description);
409 # Only return if we have actual content after cleaning
410 return !empty($twitter_description) ? $twitter_description : null;
411 }
412 }
413 }
414
415 # Fallback: Check header_html_insertion for twitter:description meta tag
416 if (!empty($seo_data['header_html_insertion'])) {
417 $html_content = $seo_data['header_html_insertion'];
418
419 # Match <meta name="twitter:description" content="..."> (name and content in any order)
420 if (preg_match('/<meta\s+[^>]*name=["\']twitter:description["\'][^>]*content="([^"]+)"[^>]*>/i', $html_content, $matches)
421 || preg_match('/<meta\s+[^>]*name=["\']twitter:description["\'][^>]*content=\'([^\']+)\'[^>]*>/i', $html_content, $matches)
422 || preg_match('/<meta\s+[^>]*content="([^"]+)"[^>]*name=["\']twitter:description["\'][^>]*>/i', $html_content, $matches)
423 || preg_match('/<meta\s+[^>]*content=\'([^\']+)\'[^>]*name=["\']twitter:description["\'][^>]*>/i', $html_content, $matches)) {
424 $twitter_description = sanitize_textarea_field(trim($matches[1]));
425 $twitter_description = metasync_clean_seo_variables($twitter_description);
426 return !empty($twitter_description) ? $twitter_description : null;
427 }
428 }
429
430 return null;
431 }
432
433 /**
434 * Extract image alt text data from OTTO response data
435 *
436 * @param array $seo_data The SEO data from OTTO API
437 * @return array Array of image URLs and their alt text
438 */
439 function metasync_extract_image_alt_text($seo_data) {
440 $image_alt_data = array();
441
442 # Check body_substitutions for images
443 if (!empty($seo_data['body_substitutions']['images']) && is_array($seo_data['body_substitutions']['images'])) {
444 foreach ($seo_data['body_substitutions']['images'] as $image_url => $alt_text) {
445 if (!empty($alt_text)) {
446 $sanitized_url = esc_url_raw($image_url);
447 $sanitized_alt = sanitize_text_field($alt_text);
448 $image_alt_data[$sanitized_url] = $sanitized_alt;
449 }
450 }
451 }
452
453 return $image_alt_data;
454 }
455
456 /**
457 * Extract heading data from OTTO response data
458 *
459 * @param array $seo_data The SEO data from OTTO API
460 * @return array Array of heading recommendations
461 */
462 function metasync_extract_headings($seo_data) {
463 $headings_data = array();
464
465 # Check body_substitutions for headings
466 if (!empty($seo_data['body_substitutions']['headings']) && is_array($seo_data['body_substitutions']['headings'])) {
467 foreach ($seo_data['body_substitutions']['headings'] as $heading) {
468 if (isset($heading['type']) && isset($heading['recommended_value']) && !empty($heading['recommended_value'])) {
469 $headings_data[] = array(
470 'type' => sanitize_text_field($heading['type']),
471 'current_value' => sanitize_text_field($heading['current_value'] ?? ''),
472 'recommended_value' => sanitize_text_field($heading['recommended_value'])
473 );
474 }
475 }
476 }
477
478 return $headings_data;
479 }
480
481 /**
482 * Extract structured data from OTTO response data
483 *
484 * @param array $seo_data The SEO data from OTTO API
485 * @return string|null The structured data JSON or null if not found
486 */
487 function metasync_extract_structured_data($seo_data) {
488 # Check header_html_insertion for structured data
489 if (!empty($seo_data['header_html_insertion'])) {
490 $html_content = $seo_data['header_html_insertion'];
491
492 # Look for ALL JSON-LD structured data blocks
493 if (preg_match_all('/<script[^>]*type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is', $html_content, $matches)) {
494 $json_blocks = array_filter(array_map('trim', $matches[1]));
495 if (empty($json_blocks)) {
496 return null;
497 }
498
499 # Single block — return as-is (backward compatible)
500 if (count($json_blocks) === 1) {
501 return reset($json_blocks);
502 }
503
504 # Multiple blocks — merge into a single @graph structure
505 $all_entities = [];
506 foreach ($json_blocks as $json_string) {
507 $decoded = json_decode($json_string, true);
508 if (!is_array($decoded)) {
509 continue;
510 }
511 # Unwrap existing @graph arrays
512 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
513 foreach ($decoded['@graph'] as $entity) {
514 unset($entity['@context']);
515 $all_entities[] = $entity;
516 }
517 } elseif (isset($decoded['@type'])) {
518 unset($decoded['@context']);
519 $all_entities[] = $decoded;
520 } elseif (isset($decoded[0])) {
521 # Plain JSON array of entities (e.g., ImageObject array)
522 foreach ($decoded as $entity) {
523 if (is_array($entity)) {
524 unset($entity['@context']);
525 $all_entities[] = $entity;
526 }
527 }
528 }
529 }
530
531 if (!empty($all_entities)) {
532 return wp_json_encode([
533 '@context' => 'https://schema.org',
534 '@graph' => $all_entities,
535 ]);
536 }
537 }
538 }
539
540 return null;
541 }
542
543 /**
544 * Update comprehensive SEO meta fields for WordPress and popular SEO plugins
545 *
546 * @param int $post_id WordPress post ID
547 * @param array $seo_data Complete SEO data from OTTO
548 * @return array Success status and updated flags for all fields
549 */
550 function metasync_update_comprehensive_seo_fields($post_id, $seo_data) {
551 if (!$post_id || empty($seo_data)) {
552 return array(
553 'updated' => false,
554 'fields_updated' => array()
555 );
556 }
557
558 $fields_updated = array();
559 $any_updated = false;
560
561 try {
562 # OPTIMIZED: Load ALL post meta in single query instead of 10+ separate queries
563 $all_meta = get_post_custom($post_id);
564
565 # Check if meta descriptions are enabled in admin settings
566 $metasync_options = get_option('metasync_options');
567 # $enable_metadesc = $metasync_options['general']['enable_metadesc'] ?? '';
568
569 # Extract all SEO data
570 $meta_title = metasync_extract_meta_title($seo_data);
571 $meta_description = metasync_extract_meta_description($seo_data);
572
573 # we are checking the metadesc only in main function
574 # If meta descriptions are disabled, don't process meta tags (title and description)
575 # if ($enable_metadesc !== 'true' && $enable_metadesc !== true) {
576 # Don't process meta title and description - return early
577 # return array(
578 # 'updated' => false,
579 # 'fields_updated' => array()
580 # );
581 # }
582 $meta_keywords = metasync_extract_meta_keywords($seo_data);
583 $og_title = metasync_extract_og_title($seo_data);
584 $og_description = metasync_extract_og_description($seo_data);
585 $twitter_title = metasync_extract_twitter_title($seo_data);
586 $twitter_description = metasync_extract_twitter_description($seo_data);
587 $image_alt_data = metasync_extract_image_alt_text($seo_data);
588 $headings_data = metasync_extract_headings($seo_data);
589 $structured_data = metasync_extract_structured_data($seo_data);
590
591 # Update basic meta fields (title and description)
592 $basic_result = metasync_update_seo_meta_fields($post_id, $meta_title, $meta_description);
593 if ($basic_result['title_updated']) {
594 # $fields_updated['meta_title'] = $meta_title;
595 $fields_updated['meta_title'] = $meta_title ?? ''; # Use empty string if null
596 $any_updated = true;
597 }
598 if ($basic_result['description_updated']) {
599 # $fields_updated['meta_description'] = $meta_description;
600 $fields_updated['meta_description'] = $meta_description ?? ''; # Use empty string if null
601 $any_updated = true;
602 }
603
604 # Update meta keywords (including empty values for rollback)
605 # if ($meta_keywords !== null) {
606 # $current_keywords = get_post_meta($post_id, '_metasync_otto_keywords', true);
607 # Update meta keywords (including clearing when OTTO returns null)
608 $current_keywords = $all_meta['_metasync_otto_keywords'][0] ?? '';
609 if ($meta_keywords !== null) {
610 # OTTO provided keywords - update if different
611 if ($meta_keywords !== $current_keywords) {
612 update_post_meta($post_id, '_metasync_otto_keywords', $meta_keywords);
613 $fields_updated['meta_keywords'] = $meta_keywords;
614 $any_updated = true;
615 }
616 } else {
617 # OTTO returned null for keywords - clear existing if it exists
618 if (!empty($current_keywords)) {
619 update_post_meta($post_id, '_metasync_otto_keywords', '');
620 $fields_updated['meta_keywords'] = '';
621 $any_updated = true;
622 }
623 }
624
625 # Collect AIOSEO field updates to write in a single DB call at the end
626 $aioseo_field_updates = [];
627
628 # PERSISTENCE: meta_keywords → _metasync_focus_keyword + SEO plugin keyword fields
629 if (class_exists('Metasync_Otto_Persistence_Settings') &&
630 Metasync_Otto_Persistence_Settings::should_persist('meta_keywords') &&
631 $meta_keywords !== null) {
632 update_post_meta($post_id, '_metasync_focus_keyword', sanitize_text_field($meta_keywords));
633 $fields_updated['meta_keywords_persisted'] = true;
634 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
635 update_post_meta($post_id, 'rank_math_focus_keyword', sanitize_text_field($meta_keywords));
636 }
637 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
638 update_post_meta($post_id, '_yoast_wpseo_focuskw', sanitize_text_field($meta_keywords));
639 }
640 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
641 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
642 # AIOSEO v4 stores focus keyphrase as JSON in the 'keyphrases' column,
643 # not as a plain 'keywords' column. Encode it in the expected format.
644 $aioseo_field_updates['keyphrases'] = wp_json_encode([
645 'focus' => [
646 'keyphrase' => sanitize_text_field($meta_keywords),
647 'score' => 0,
648 'analysis' => new stdClass(),
649 ],
650 'additional' => [],
651 ]);
652 }
653 }
654
655 # Update Open Graph fields (including empty values for rollback)
656 # if ($og_title !== null) {
657 # $current_og_title = get_post_meta($post_id, '_metasync_otto_og_title', true);
658 # Update Open Graph fields (including clearing when OTTO returns null)
659 $current_og_title = $all_meta['_metasync_otto_og_title'][0] ?? '';
660 if ($og_title !== null) {
661 # OTTO provided OG title - update if different
662 if ($og_title !== $current_og_title) {
663 update_post_meta($post_id, '_metasync_otto_og_title', $og_title);
664 $fields_updated['og_title'] = $og_title;
665 $any_updated = true;
666 }
667 } else {
668 # OTTO returned null for OG title - clear existing if it exists
669 if (!empty($current_og_title)) {
670 update_post_meta($post_id, '_metasync_otto_og_title', '');
671 $fields_updated['og_title'] = '';
672 $any_updated = true;
673 }
674 }
675
676 # PERSISTENCE: og_title → _metasync_og_title + RankMath/Yoast/AIOSEO OG fields
677 if (class_exists('Metasync_Otto_Persistence_Settings') &&
678 Metasync_Otto_Persistence_Settings::should_persist('og_title') &&
679 $og_title !== null) {
680 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($og_title));
681 $fields_updated['og_title_persisted'] = true;
682 # Also write to SEO plugin OG fields so their output picks up OTTO data
683 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
684 update_post_meta($post_id, 'rank_math_facebook_title', sanitize_text_field($og_title));
685 }
686 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
687 update_post_meta($post_id, '_yoast_wpseo_opengraph-title', sanitize_text_field($og_title));
688 }
689 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
690 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
691 $aioseo_field_updates['og_title'] = sanitize_text_field($og_title);
692 }
693 }
694
695 # if ($og_description !== null) {
696 # $current_og_desc = get_post_meta($post_id, '_metasync_otto_og_description', true);
697 $current_og_desc = $all_meta['_metasync_otto_og_description'][0] ?? '';
698 if ($og_description !== null) {
699 # OTTO provided OG description - update if different
700 if ($og_description !== $current_og_desc) {
701 update_post_meta($post_id, '_metasync_otto_og_description', $og_description);
702 $fields_updated['og_description'] = $og_description;
703 $any_updated = true;
704 }
705 } else {
706 # OTTO returned null for OG description - clear existing if it exists
707 if (!empty($current_og_desc)) {
708 update_post_meta($post_id, '_metasync_otto_og_description', '');
709 $fields_updated['og_description'] = '';
710 $any_updated = true;
711 }
712 }
713
714 # PERSISTENCE: og_description → _metasync_og_description + RankMath/Yoast/AIOSEO OG fields
715 if (class_exists('Metasync_Otto_Persistence_Settings') &&
716 Metasync_Otto_Persistence_Settings::should_persist('og_description') &&
717 $og_description !== null) {
718 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($og_description));
719 $fields_updated['og_description_persisted'] = true;
720 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
721 update_post_meta($post_id, 'rank_math_facebook_description', sanitize_textarea_field($og_description));
722 }
723 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
724 update_post_meta($post_id, '_yoast_wpseo_opengraph-description', sanitize_textarea_field($og_description));
725 }
726 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
727 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
728 $aioseo_field_updates['og_description'] = sanitize_textarea_field($og_description);
729 }
730 }
731
732 # Update Twitter fields (including empty values for rollback)
733 # if ($twitter_title !== null) {
734 # $current_twitter_title = get_post_meta($post_id, '_metasync_otto_twitter_title', true);
735 # Update Twitter fields (including clearing when OTTO returns null)
736 $current_twitter_title = $all_meta['_metasync_otto_twitter_title'][0] ?? '';
737 if ($twitter_title !== null) {
738 # OTTO provided Twitter title - update if different
739 if ($twitter_title !== $current_twitter_title) {
740 update_post_meta($post_id, '_metasync_otto_twitter_title', $twitter_title);
741 $fields_updated['twitter_title'] = $twitter_title;
742 $any_updated = true;
743 }
744 } else {
745 # OTTO returned null for Twitter title - clear existing if it exists
746 if (!empty($current_twitter_title)) {
747 update_post_meta($post_id, '_metasync_otto_twitter_title', '');
748 $fields_updated['twitter_title'] = '';
749 $any_updated = true;
750 }
751 }
752
753 # PERSISTENCE: twitter_title → _metasync_twitter_title + RankMath/Yoast/AIOSEO Twitter fields
754 if (class_exists('Metasync_Otto_Persistence_Settings') &&
755 Metasync_Otto_Persistence_Settings::should_persist('twitter_title') &&
756 $twitter_title !== null) {
757 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($twitter_title));
758 $fields_updated['twitter_title_persisted'] = true;
759 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
760 update_post_meta($post_id, 'rank_math_twitter_title', sanitize_text_field($twitter_title));
761 }
762 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
763 update_post_meta($post_id, '_yoast_wpseo_twitter-title', sanitize_text_field($twitter_title));
764 }
765 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
766 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
767 $aioseo_field_updates['twitter_title'] = sanitize_text_field($twitter_title);
768 }
769 }
770
771 # if ($twitter_description !== null) {
772 # $current_twitter_desc = get_post_meta($post_id, '_metasync_otto_twitter_description', true);
773 $current_twitter_desc = $all_meta['_metasync_otto_twitter_description'][0] ?? '';
774 if ($twitter_description !== null) {
775 # OTTO provided Twitter description - update if different
776 if ($twitter_description !== $current_twitter_desc) {
777 update_post_meta($post_id, '_metasync_otto_twitter_description', $twitter_description);
778 $fields_updated['twitter_description'] = $twitter_description;
779 $any_updated = true;
780 }
781 } else {
782 # OTTO returned null for Twitter description - clear existing if it exists
783 if (!empty($current_twitter_desc)) {
784 update_post_meta($post_id, '_metasync_otto_twitter_description', '');
785 $fields_updated['twitter_description'] = '';
786 $any_updated = true;
787 }
788 }
789
790 # PERSISTENCE: twitter_description → _metasync_twitter_description + RankMath/Yoast/AIOSEO Twitter fields
791 if (class_exists('Metasync_Otto_Persistence_Settings') &&
792 Metasync_Otto_Persistence_Settings::should_persist('twitter_description') &&
793 $twitter_description !== null) {
794 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($twitter_description));
795 $fields_updated['twitter_description_persisted'] = true;
796 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
797 update_post_meta($post_id, 'rank_math_twitter_description', sanitize_textarea_field($twitter_description));
798 }
799 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
800 update_post_meta($post_id, '_yoast_wpseo_twitter-description', sanitize_textarea_field($twitter_description));
801 }
802 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
803 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
804 $aioseo_field_updates['twitter_description'] = sanitize_textarea_field($twitter_description);
805 }
806 }
807
808 # Update image alt text data
809 if (!empty($image_alt_data)) {
810 $current_image_alt = $all_meta['_metasync_otto_image_alt_data'][0] ?? '';
811 $image_alt_json = json_encode($image_alt_data);
812 if ($image_alt_json !== $current_image_alt) {
813 update_post_meta($post_id, '_metasync_otto_image_alt_data', $image_alt_json);
814 $fields_updated['image_alt_data'] = $image_alt_data;
815 $any_updated = true;
816 }
817
818 # PERSISTENCE: If enabled, also update the Media Library directly
819 if (class_exists('Metasync_Otto_Persistence_Settings') &&
820 class_exists('Metasync_Otto_Persistence_Handler') &&
821 Metasync_Otto_Persistence_Settings::should_persist('image_alt_text')) {
822 $persistence_result = Metasync_Otto_Persistence_Handler::persist_image_alt_text($image_alt_data);
823 if ($persistence_result['success']) {
824 $fields_updated['image_alt_text_persisted'] = $persistence_result['updated_count'];
825 }
826 }
827 }
828
829 # Update headings data
830 if (!empty($headings_data)) {
831 $current_headings = $all_meta['_metasync_otto_headings_data'][0] ?? '';
832 $headings_json = json_encode($headings_data);
833 if ($headings_json !== $current_headings) {
834 update_post_meta($post_id, '_metasync_otto_headings_data', $headings_json);
835 $fields_updated['headings_data'] = $headings_data;
836 $any_updated = true;
837 }
838 }
839
840 # PERSISTENCE: Process post_content modifications (headings and links) together
841 # to avoid race conditions where one overwrites the other
842 if (class_exists('Metasync_Otto_Persistence_Settings') &&
843 class_exists('Metasync_Otto_Persistence_Handler')) {
844
845 $persist_headings = Metasync_Otto_Persistence_Settings::should_persist('heading_changes') && !empty($headings_data);
846 $persist_links = Metasync_Otto_Persistence_Settings::should_persist('link_corrections') && !empty($seo_data['body_substitutions']['links']);
847
848 # Only process if at least one is enabled
849 if ($persist_headings || $persist_links) {
850 $post = get_post($post_id);
851 if ($post) {
852 # Check if post uses a page builder - skip content modification to avoid breaking them
853 $uses_page_builder = false;
854 $page_builder_name = '';
855
856 # Elementor detection (array key lookups instead of DB queries)
857 if ((isset($all_meta['_elementor_edit_mode'][0]) && $all_meta['_elementor_edit_mode'][0] === 'builder') ||
858 (isset($all_meta['_elementor_data'][0]) && !empty($all_meta['_elementor_data'][0]))) {
859 $uses_page_builder = true;
860 $page_builder_name = 'Elementor';
861 }
862
863 # Divi Builder detection
864 if (!$uses_page_builder && (
865 (isset($all_meta['_et_pb_use_builder'][0]) && $all_meta['_et_pb_use_builder'][0] === 'on') ||
866 strpos($post->post_content, '[et_pb_') !== false)) {
867 $uses_page_builder = true;
868 $page_builder_name = 'Divi';
869 }
870
871 # WPBakery/Visual Composer detection
872 if (!$uses_page_builder && (
873 strpos($post->post_content, '[vc_') !== false ||
874 strpos($post->post_content, '[/vc_') !== false)) {
875 $uses_page_builder = true;
876 $page_builder_name = 'WPBakery';
877 }
878
879 # Beaver Builder detection
880 if (!$uses_page_builder &&
881 (isset($all_meta['_fl_builder_enabled'][0]) && $all_meta['_fl_builder_enabled'][0] === '1')) {
882 $uses_page_builder = true;
883 $page_builder_name = 'Beaver Builder';
884 }
885
886 # Oxygen Builder detection
887 if (!$uses_page_builder &&
888 (isset($all_meta['ct_builder_shortcodes'][0]) && !empty($all_meta['ct_builder_shortcodes'][0]))) {
889 $uses_page_builder = true;
890 $page_builder_name = 'Oxygen';
891 }
892
893 # Brizy detection
894 if (!$uses_page_builder &&
895 (isset($all_meta['brizy_post_uid'][0]) && !empty($all_meta['brizy_post_uid'][0]))) {
896 $uses_page_builder = true;
897 $page_builder_name = 'Brizy';
898 }
899
900 # Skip content modification for page builder posts
901 if ($uses_page_builder) {
902 $fields_updated['content_persistence_skipped'] = sprintf(
903 'Post uses %s page builder - content modifications skipped to prevent data corruption',
904 $page_builder_name
905 );
906 } else {
907 # Safe to modify post_content for standard Gutenberg/Classic editor posts
908 $content = $post->post_content;
909 $original_content = $content;
910
911 # Apply heading changes first
912 if ($persist_headings) {
913 foreach ($headings_data as $heading) {
914 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
915 continue;
916 }
917 if ($heading['current_value'] === $heading['recommended_value']) {
918 continue;
919 }
920 $tag = strtolower(sanitize_text_field($heading['type']));
921 $pattern = '/(<' . preg_quote($tag, '/') . '[^>]*>)(.*?)(<\/' . preg_quote($tag, '/') . '>)/is';
922 $normalized_current = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
923 $safe_recommended = sanitize_text_field($heading['recommended_value']);
924 $count = 0;
925 $new_content = preg_replace_callback(
926 $pattern,
927 function ($m) use ($normalized_current, $safe_recommended, &$count) {
928 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
929 if (strcasecmp($inner_text, $normalized_current) === 0) {
930 $count++;
931 return $m[1] . $safe_recommended . $m[3];
932 }
933 return $m[0];
934 },
935 $content
936 );
937 if ($count > 0 && $new_content !== null) {
938 $content = $new_content;
939 $fields_updated['heading_changes_persisted'] = ($fields_updated['heading_changes_persisted'] ?? 0) + $count;
940 }
941 }
942 }
943
944 # Apply link corrections second
945 if ($persist_links) {
946 foreach ($seo_data['body_substitutions']['links'] as $old_url => $new_url) {
947 $old_url = esc_url_raw($old_url);
948 $new_url = esc_url_raw($new_url);
949 if (empty($old_url) || empty($new_url) || $old_url === $new_url) {
950 continue;
951 }
952 $count_before = substr_count($content, $old_url);
953 if ($count_before > 0) {
954 $content = str_replace($old_url, $new_url, $content);
955 $fields_updated['link_corrections_persisted'] = ($fields_updated['link_corrections_persisted'] ?? 0) + $count_before;
956 }
957 }
958 }
959
960 # Save if content changed
961 if ($content !== $original_content) {
962 wp_update_post([
963 'ID' => $post_id,
964 'post_content' => $content,
965 ]);
966 clean_post_cache($post_id);
967 $any_updated = true;
968
969 # Log combined content persistence to sync history
970 if (class_exists('Metasync_Sync_History_Database')) {
971 try {
972 $sync_history = new Metasync_Sync_History_Database();
973 $headings_count = isset($fields_updated['heading_changes_persisted']) ? $fields_updated['heading_changes_persisted'] : 0;
974 $links_count = isset($fields_updated['link_corrections_persisted']) ? $fields_updated['link_corrections_persisted'] : 0;
975
976 $operations = [];
977 if ($headings_count > 0) {
978 $operations[] = sprintf('%d heading%s', $headings_count, $headings_count === 1 ? '' : 's');
979 }
980 if ($links_count > 0) {
981 $operations[] = sprintf('%d link%s', $links_count, $links_count === 1 ? '' : 's');
982 }
983
984 $title = 'Updated ' . implode(' and ', $operations) . ' in post content';
985
986 $sync_history->add([
987 'title' => $title,
988 'source' => 'OTTO Persistence',
989 'status' => 'published',
990 'content_type' => 'combined_content_update',
991 'url' => get_permalink($post_id),
992 'meta_data' => json_encode([
993 'post_id' => $post_id,
994 'headings_updated' => $headings_count,
995 'links_updated' => $links_count,
996 ]),
997 'created_at' => current_time('mysql'),
998 ]);
999 } catch (Exception $e) {
1000 // Failed to log, continue
1001 }
1002 }
1003 }
1004 }
1005 }
1006 }
1007 }
1008
1009 # Update structured data (including empty values for rollback)
1010 # if ($structured_data !== null) {
1011 # $current_structured = get_post_meta($post_id, '_metasync_otto_structured_data', true);
1012 # Update structured data (including clearing when OTTO returns null)
1013 $current_structured = $all_meta['_metasync_otto_structured_data'][0] ?? '';
1014 if ($structured_data !== null) {
1015 # OTTO provided structured data - update if different
1016 if ($structured_data !== $current_structured) {
1017 # wp_slash counteracts wp_unslash inside update_metadata, preserving
1018 # backslash-escaped quotes (e.g. \") in the JSON string.
1019 update_post_meta($post_id, '_metasync_otto_structured_data', wp_slash($structured_data));
1020 $fields_updated['structured_data'] = $structured_data;
1021 $any_updated = true;
1022 }
1023 } else {
1024 # OTTO returned null for structured data - clear existing if it exists
1025 if (!empty($current_structured)) {
1026 update_post_meta($post_id, '_metasync_otto_structured_data', '');
1027 $fields_updated['structured_data'] = '';
1028 $any_updated = true;
1029 }
1030 }
1031
1032 # PERSISTENCE: structured_data → metasync_schema_markup
1033 # Always write otto_jsonld during an active sync regardless of the persistence flag.
1034 # The persistence flag controls whether this data survives OTTO being disabled/paused
1035 # — see OTTO deactivation path.
1036 if (class_exists('Metasync_Otto_Persistence_Settings') &&
1037 !empty($structured_data)) {
1038 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1039 if (!is_array($existing_schema)) {
1040 $existing_schema = [];
1041 }
1042 $existing_schema['otto_jsonld'] = [
1043 'type' => 'json_ld',
1044 'data' => $structured_data,
1045 'source' => 'otto',
1046 'last_synced_at' => current_time('mysql'),
1047 ];
1048 # wp_slash counteracts wp_unslash inside update_metadata, preserving
1049 # backslash-escaped quotes in JSON strings within the array.
1050 update_post_meta($post_id, 'metasync_schema_markup', wp_slash($existing_schema));
1051 $fields_updated['structured_data_persisted'] = true;
1052
1053 # Cross-plugin schema persistence: sync structured_data to RankMath, Yoast, AIOSEO
1054 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
1055 $rankmath_schemas = metasync_convert_jsonld_to_rankmath($structured_data);
1056 foreach ($rankmath_schemas as $schema_type => $schema_data) {
1057 update_post_meta($post_id, 'rank_math_schema_' . $schema_type, wp_slash($schema_data));
1058 }
1059 }
1060 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
1061 $yoast_types = metasync_extract_yoast_schema_types($structured_data);
1062 if (!empty($yoast_types['article_type'])) {
1063 update_post_meta($post_id, '_yoast_wpseo_schema_article_type', $yoast_types['article_type']);
1064 }
1065 if (!empty($yoast_types['page_type'])) {
1066 update_post_meta($post_id, '_yoast_wpseo_schema_page_type', $yoast_types['page_type']);
1067 }
1068 // Also update Yoast's indexable cache so the change renders immediately
1069 if (!empty($yoast_types['article_type']) || !empty($yoast_types['page_type'])) {
1070 global $wpdb;
1071 $yoast_indexable_table = $wpdb->prefix . 'yoast_indexable';
1072 $update_cols = array();
1073 $update_vals = array();
1074 if (!empty($yoast_types['article_type'])) {
1075 $update_cols[] = 'schema_article_type = %s';
1076 $update_vals[] = $yoast_types['article_type'];
1077 }
1078 if (!empty($yoast_types['page_type'])) {
1079 $update_cols[] = 'schema_page_type = %s';
1080 $update_vals[] = $yoast_types['page_type'];
1081 }
1082 $update_vals[] = $post_id;
1083 $wpdb->query($wpdb->prepare(
1084 "UPDATE {$yoast_indexable_table} SET " . implode(', ', $update_cols) . " WHERE object_id = %d AND object_type = 'post'",
1085 $update_vals
1086 ));
1087 }
1088 }
1089 // AIOSEO schema persistence skipped — AIOSEO Free locks Article/custom schema behind Pro paywall.
1090 // The graphs array (Pro-only) and default.graphName (UI-gated) cannot reliably render OTTO schema on Free.
1091 }
1092
1093 # UN-DEPLOY CLEAR — OTTO sent no structured data this sync, so remove the
1094 # copy we previously persisted. Without this the stale otto_jsonld lingers in the DB
1095 # and is re-served by output_schema_markup() once OTTO is disabled (path),
1096 # making schema "survive" an un-deploy. This mirrors the _metasync_otto_structured_data
1097 # clear above and the title/description un-deploy behaviour: we only remove OUR
1098 # own otto_jsonld key and leave any user-authored per-post schema (types/enabled) intact.
1099 if (class_exists('Metasync_Otto_Persistence_Settings') && empty($structured_data)) {
1100 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1101 if (is_array($existing_schema) && array_key_exists('otto_jsonld', $existing_schema)) {
1102 unset($existing_schema['otto_jsonld']);
1103 if (empty($existing_schema)) {
1104 # Nothing but the OTTO copy was stored — drop the whole meta row rather
1105 # than leaving an empty array behind.
1106 delete_post_meta($post_id, 'metasync_schema_markup');
1107 } else {
1108 update_post_meta($post_id, 'metasync_schema_markup', wp_slash($existing_schema));
1109 }
1110 $fields_updated['structured_data_persisted'] = false;
1111 $any_updated = true;
1112 }
1113 }
1114
1115 # PERSISTENCE: canonical_url → _metasync_canonical_url + SEO plugin canonical fields
1116 if (class_exists('Metasync_Otto_Persistence_Settings') &&
1117 Metasync_Otto_Persistence_Settings::should_persist('canonical_url') &&
1118 !empty($seo_data['canonical_url'])) {
1119 # Validate before persisting: never store or mirror a corrupted /
1120 # non-URL canonical into MetaSync or SEO-plugin storage.
1121 $canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($seo_data['canonical_url']);
1122 if (!empty($canonical)) {
1123 update_post_meta($post_id, '_metasync_canonical_url', $canonical);
1124 $fields_updated['canonical_url_persisted'] = $canonical;
1125 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
1126 update_post_meta($post_id, 'rank_math_canonical_url', $canonical);
1127 }
1128 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
1129 update_post_meta($post_id, '_yoast_wpseo_canonical', $canonical);
1130 }
1131 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1132 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1133 $aioseo_field_updates['canonical_url'] = $canonical;
1134 }
1135 }
1136 }
1137
1138 # Write all collected AIOSEO field updates in a single DB call
1139 if (!empty($aioseo_field_updates)) {
1140 global $wpdb;
1141 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1142 $aioseo_field_updates['updated'] = current_time('mysql');
1143 $row_exists = $wpdb->get_var($wpdb->prepare(
1144 "SELECT id FROM {$aioseo_table} WHERE post_id = %d", $post_id
1145 ));
1146
1147 if ($row_exists) {
1148 $wpdb->update($aioseo_table, $aioseo_field_updates, ['post_id' => $post_id]);
1149 } else {
1150 $aioseo_field_updates['post_id'] = $post_id;
1151 $aioseo_field_updates['created'] = current_time('mysql');
1152 $wpdb->insert($aioseo_table, $aioseo_field_updates);
1153 }
1154 }
1155
1156 // Sync to all active SEO plugins
1157 if (class_exists('Metasync_Plugin_Sync') && $any_updated) {
1158 Metasync_Plugin_Sync::get_instance()->sync_post($post_id);
1159 }
1160
1161 # Store timestamp of last OTTO SEO update if any change
1162 if ($any_updated) {
1163 update_post_meta($post_id, '_metasync_otto_last_update', current_time('timestamp'));
1164 }
1165
1166 return array(
1167 'updated' => $any_updated,
1168 'fields_updated' => $fields_updated
1169 );
1170
1171 } catch (Exception $e) {
1172 return array(
1173 'updated' => false,
1174 'fields_updated' => array()
1175 );
1176 }
1177 }
1178
1179 /**
1180 * Update comprehensive SEO meta fields for WordPress categories
1181 *
1182 * @param int $category_id Category term ID
1183 * @param array $seo_data Complete SEO data from OTTO
1184 * @return array Success status and updated flags for all fields
1185 */
1186 function metasync_update_comprehensive_category_seo_fields($category_id, $seo_data) {
1187 if (!$category_id || empty($seo_data)) {
1188 return array(
1189 'updated' => false,
1190 'fields_updated' => array()
1191 );
1192 }
1193
1194 $fields_updated = array();
1195 $any_updated = false;
1196
1197 try {
1198 # Check if meta descriptions are enabled in admin settings
1199 $metasync_options = get_option('metasync_options');
1200
1201 # Extract all SEO data
1202 $meta_title = metasync_extract_meta_title($seo_data);
1203 $meta_description = metasync_extract_meta_description($seo_data);
1204
1205 # If meta descriptions are disabled, don't process meta tags (title and description)
1206 # if ($enable_metadesc !== 'true' && $enable_metadesc !== true) {
1207 # Don't process meta title and description - return early
1208 # return array(
1209 # 'updated' => false,
1210 # 'fields_updated' => array()
1211 # );
1212 # }
1213 $meta_keywords = metasync_extract_meta_keywords($seo_data);
1214 $og_title = metasync_extract_og_title($seo_data);
1215 $og_description = metasync_extract_og_description($seo_data);
1216 $twitter_title = metasync_extract_twitter_title($seo_data);
1217 $twitter_description = metasync_extract_twitter_description($seo_data);
1218 $image_alt_data = metasync_extract_image_alt_text($seo_data);
1219 $headings_data = metasync_extract_headings($seo_data);
1220 $structured_data = metasync_extract_structured_data($seo_data);
1221
1222 # Update basic meta fields (title and description)
1223 $basic_result = metasync_update_category_seo_meta_fields($category_id, $meta_title, $meta_description);
1224 if ($basic_result) {
1225 # if ($meta_title) $fields_updated['meta_title'] = $meta_title;
1226 # if ($meta_description) $fields_updated['meta_description'] = $meta_description;
1227 if ($meta_title !== null) $fields_updated['meta_title'] = $meta_title ?? '';
1228 if ($meta_description !== null) $fields_updated['meta_description'] = $meta_description ?? '';
1229 $any_updated = true;
1230 }
1231
1232 # Update meta keywords (including empty values for rollback)
1233 if ($meta_keywords !== null) {
1234 $current_keywords = get_term_meta($category_id, '_metasync_otto_keywords', true);
1235 if ($meta_keywords !== $current_keywords) {
1236 update_term_meta($category_id, '_metasync_otto_keywords', $meta_keywords);
1237 $fields_updated['meta_keywords'] = $meta_keywords;
1238 $any_updated = true;
1239 }
1240 }
1241
1242 # Update Open Graph fields (including empty values for rollback)
1243 if ($og_title !== null) {
1244 $current_og_title = get_term_meta($category_id, '_metasync_otto_og_title', true);
1245 if ($og_title !== $current_og_title) {
1246 update_term_meta($category_id, '_metasync_otto_og_title', $og_title);
1247 $fields_updated['og_title'] = $og_title;
1248 $any_updated = true;
1249 }
1250 }
1251
1252 if ($og_description !== null) {
1253 $current_og_desc = get_term_meta($category_id, '_metasync_otto_og_description', true);
1254 if ($og_description !== $current_og_desc) {
1255 update_term_meta($category_id, '_metasync_otto_og_description', $og_description);
1256 $fields_updated['og_description'] = $og_description;
1257 $any_updated = true;
1258 }
1259 }
1260
1261 # Update Twitter fields (including empty values for rollback)
1262 if ($twitter_title !== null) {
1263 $current_twitter_title = get_term_meta($category_id, '_metasync_otto_twitter_title', true);
1264 if ($twitter_title !== $current_twitter_title) {
1265 update_term_meta($category_id, '_metasync_otto_twitter_title', $twitter_title);
1266 $fields_updated['twitter_title'] = $twitter_title;
1267 $any_updated = true;
1268 }
1269 }
1270
1271 if ($twitter_description !== null) {
1272 $current_twitter_desc = get_term_meta($category_id, '_metasync_otto_twitter_description', true);
1273 if ($twitter_description !== $current_twitter_desc) {
1274 update_term_meta($category_id, '_metasync_otto_twitter_description', $twitter_description);
1275 $fields_updated['twitter_description'] = $twitter_description;
1276 $any_updated = true;
1277 }
1278 }
1279
1280 # Update image alt text data
1281 if (!empty($image_alt_data)) {
1282 $current_image_alt = get_term_meta($category_id, '_metasync_otto_image_alt_data', true);
1283 $image_alt_json = json_encode($image_alt_data);
1284 if ($image_alt_json !== $current_image_alt) {
1285 update_term_meta($category_id, '_metasync_otto_image_alt_data', $image_alt_json);
1286 $fields_updated['image_alt_data'] = $image_alt_data;
1287 $any_updated = true;
1288 }
1289 }
1290
1291 # Update headings data
1292 if (!empty($headings_data)) {
1293 $current_headings = get_term_meta($category_id, '_metasync_otto_headings_data', true);
1294 $headings_json = json_encode($headings_data);
1295 if ($headings_json !== $current_headings) {
1296 update_term_meta($category_id, '_metasync_otto_headings_data', $headings_json);
1297 $fields_updated['headings_data'] = $headings_data;
1298 $any_updated = true;
1299 }
1300 }
1301
1302 # Update structured data (including empty values for rollback)
1303 if ($structured_data !== null) {
1304 $current_structured = get_term_meta($category_id, '_metasync_otto_structured_data', true);
1305 if ($structured_data !== $current_structured) {
1306 update_term_meta($category_id, '_metasync_otto_structured_data', $structured_data);
1307 $fields_updated['structured_data'] = $structured_data;
1308 $any_updated = true;
1309 }
1310 }
1311
1312 # Store timestamp of last OTTO SEO update if any change
1313 if ($any_updated) {
1314 update_term_meta($category_id, '_metasync_otto_last_update', current_time('timestamp'));
1315 }
1316
1317 return array(
1318 'updated' => $any_updated,
1319 'fields_updated' => $fields_updated
1320 );
1321
1322 } catch (Exception $e) {
1323 return array(
1324 'updated' => false,
1325 'fields_updated' => array()
1326 );
1327 }
1328 }
1329
1330 /**
1331 * Update SEO meta fields for WordPress and popular SEO plugins
1332 *
1333 * @param int $post_id WordPress post ID
1334 * @param string|null $meta_title Meta title to set
1335 * @param string|null $meta_description Meta description to set
1336 * @return array|false Success status and updated flags
1337 */
1338 function metasync_update_seo_meta_fields($post_id, $meta_title, $meta_description) {
1339 if (!$post_id) {
1340 return array(
1341 'updated' => false,
1342 'title_updated' => false,
1343 'description_updated' => false,
1344 );
1345 }
1346
1347 $title_updated = false;
1348 $description_updated = false;
1349
1350 try {
1351 # Meta descriptions are always enabled by default
1352
1353 # OPTIMIZED: Load post meta in single query
1354 $all_meta = get_post_custom($post_id);
1355
1356 # Compare against previously stored OTTO values to avoid unnecessary updates
1357 $current_title = $all_meta['_metasync_otto_title'][0] ?? '';
1358 $current_description = $all_meta['_metasync_otto_description'][0] ?? '';
1359
1360 # ENHANCED LOGIC: Handle clearing data when OTTO returns null (undeployed changes)
1361 # If OTTO returns null for title/description, we should clear existing OTTO data
1362 # Capture whether each field is an un-deploy "clear" (OTTO null) vs a real value.
1363 # On a clear we still wipe MetaSync's own staging/permanent fields, but must NOT
1364 # blank the SEO-plugin fields (empty meta is worse for SEO than a stale value).
1365 $title_is_clear = ($meta_title === null);
1366 $description_is_clear = ($meta_description === null);
1367
1368 $should_update_title = false;
1369 $should_update_description = false;
1370
1371 if ($meta_title !== null) {
1372 # OTTO provided a title - update if different from current
1373 $should_update_title = $meta_title !== $current_title;
1374 } else {
1375 # OTTO returned null for title - clear existing OTTO title if it exists
1376 $should_update_title = !empty($current_title);
1377 $meta_title = ''; # Set to empty string to clear the field
1378 }
1379
1380 if ($meta_description !== null) {
1381 # OTTO provided a description - update if different from current
1382 $should_update_description = $meta_description !== $current_description;
1383 } else {
1384 # OTTO returned null for description - clear existing OTTO description if it exists
1385 $should_update_description = !empty($current_description);
1386 $meta_description = ''; # Set to empty string to clear the field
1387 }
1388
1389 # Check persistence settings to determine if we should store in database
1390 # This is controlled via the OTTO Persistence API
1391 $persist_title = false;
1392 $persist_description = false;
1393
1394 if (class_exists('Metasync_Otto_Persistence_Settings')) {
1395 $persist_title = Metasync_Otto_Persistence_Settings::should_persist('meta_title');
1396 $persist_description = Metasync_Otto_Persistence_Settings::should_persist('meta_description');
1397 }
1398
1399 # Decouple the permanent/SEO-plugin write from the staging-key write.
1400 # The staging key gates on (incoming != staging); the permanent native /
1401 # SEO-plugin field must update whenever persistence is ON and the native
1402 # field is missing or differs from the incoming value. This back-fills
1403 # _metasync_metatitle / _metasync_metadesc (and SEO-plugin equivalents)
1404 # when a persistence flag is enabled after OTTO has already synced.
1405 $current_native_title = $all_meta['_metasync_metatitle'][0] ?? '';
1406 $current_native_description = $all_meta['_metasync_metadesc'][0] ?? '';
1407
1408 $should_persist_title = $persist_title && ($meta_title !== $current_native_title);
1409 $should_persist_description = $persist_description && ($meta_description !== $current_native_description);
1410
1411 # Per-plugin back-fill: gate each SEO-plugin write on that plugin's OWN
1412 # stored value, not on MetaSync's native field. Once the native field
1413 # matches the incoming value, $should_persist_* go false — but an active
1414 # plugin whose own column is empty/stale must still be back-filled. Read
1415 # each plugin's current value from the already-loaded $all_meta and write
1416 # only when persistence for that field is ON, it is not a "clear", and the
1417 # plugin's stored value differs from the incoming value. (AIOSEO lives in
1418 # the aioseo_posts table, so its current values are read inside its block.)
1419 $current_rm_title = $all_meta['rank_math_title'][0] ?? '';
1420 $current_rm_description = $all_meta['rank_math_description'][0] ?? '';
1421 $current_yoast_title = $all_meta['_yoast_wpseo_title'][0] ?? '';
1422 $current_yoast_description = $all_meta['_yoast_wpseo_metadesc'][0] ?? '';
1423
1424 $should_persist_rm_title = $persist_title && !$title_is_clear && ($meta_title !== $current_rm_title);
1425 $should_persist_rm_description = $persist_description && !$description_is_clear && ($meta_description !== $current_rm_description);
1426 $should_persist_yoast_title = $persist_title && !$title_is_clear && ($meta_title !== $current_yoast_title);
1427 $should_persist_yoast_description = $persist_description && !$description_is_clear && ($meta_description !== $current_yoast_description);
1428
1429 # Always write staging keys — these are read by OTTO's own render filters
1430 # regardless of whether persistence to native WP/3rd-party fields is enabled.
1431 if ($should_update_title) {
1432 update_post_meta($post_id, '_metasync_otto_title', $meta_title);
1433 $title_updated = true;
1434 }
1435
1436 if ($should_update_description) {
1437 update_post_meta($post_id, '_metasync_otto_description', $meta_description);
1438 $description_updated = true;
1439 }
1440
1441 # Write to plugin's own meta fields only when persistence is enabled
1442 if ($should_persist_title) {
1443 update_post_meta($post_id, '_metasync_metatitle', $meta_title);
1444 }
1445
1446 if ($should_persist_description) {
1447 update_post_meta($post_id, '_metasync_metadesc', $meta_description);
1448 }
1449
1450 # Track whether any SEO-plugin field was actually back-filled, so the
1451 # timestamp / updated roll-up below also covers plugin-only writes (the
1452 # native field may already match the incoming value while a plugin's own
1453 # column is empty or stale).
1454 $plugin_title_written = false;
1455 $plugin_description_written = false;
1456
1457 # Sync to SEO plugins whenever persistence is enabled — not only when the
1458 # native field changed. Each plugin's write is gated on that plugin's own
1459 # stored value (the $should_persist_*_* flags computed above), so an active
1460 # plugin whose column is empty/stale is back-filled even though MetaSync's
1461 # native field already matches the incoming value.
1462 if ($persist_title || $persist_description) {
1463 $effective_title = $meta_title;
1464 $effective_description = $meta_description;
1465
1466 # Update RankMath SEO plugin meta fields
1467 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
1468 if ($should_persist_rm_title) {
1469 update_post_meta($post_id, 'rank_math_title', $effective_title);
1470 $plugin_title_written = true;
1471 }
1472 if ($should_persist_rm_description) {
1473 update_post_meta($post_id, 'rank_math_description', $effective_description);
1474 $plugin_description_written = true;
1475 }
1476 }
1477
1478 # Update Yoast SEO plugin meta fields
1479 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
1480 if ($should_persist_yoast_title) {
1481 update_post_meta($post_id, '_yoast_wpseo_title', $effective_title);
1482 $plugin_title_written = true;
1483 }
1484 if ($should_persist_yoast_description) {
1485 update_post_meta($post_id, '_yoast_wpseo_metadesc', $effective_description);
1486 $plugin_description_written = true;
1487 }
1488 }
1489
1490 # Update All in One SEO plugin fields (writes to aioseo_posts table, not postmeta)
1491 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1492 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1493 global $wpdb;
1494 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1495
1496 # Read the plugin's own current values so the back-fill is gated on
1497 # the aioseo_posts row, not on MetaSync's native field.
1498 $current_aioseo = $wpdb->get_row($wpdb->prepare("SELECT title, description FROM {$aioseo_table} WHERE post_id = %d", $post_id));
1499
1500 $should_persist_aioseo_title = $persist_title && !$title_is_clear && ($meta_title !== ($current_aioseo->title ?? ''));
1501 $should_persist_aioseo_description = $persist_description && !$description_is_clear && ($meta_description !== ($current_aioseo->description ?? ''));
1502
1503 $aioseo_updates = [];
1504 if ($should_persist_aioseo_title) {
1505 $aioseo_updates['title'] = $effective_title;
1506 $plugin_title_written = true;
1507 }
1508 if ($should_persist_aioseo_description) {
1509 $aioseo_updates['description'] = $effective_description;
1510 $plugin_description_written = true;
1511 }
1512
1513 if (!empty($aioseo_updates)) {
1514 $aioseo_updates['updated'] = current_time('mysql');
1515
1516 if ($current_aioseo) {
1517 $wpdb->update($aioseo_table, $aioseo_updates, ['post_id' => $post_id]);
1518 } else {
1519 $aioseo_updates['post_id'] = $post_id;
1520 $aioseo_updates['created'] = current_time('mysql');
1521 $wpdb->insert($aioseo_table, $aioseo_updates);
1522 }
1523 }
1524 }
1525 }
1526
1527 # Store timestamp if any persistence occurred — native back-fill OR a
1528 # plugin-only back-fill. Kept outside the native-field gate so a write
1529 # that only touched a SEO plugin still records the sync timestamp.
1530 if ($should_persist_title || $should_persist_description || $plugin_title_written || $plugin_description_written) {
1531 update_post_meta($post_id, '_metasync_otto_last_update', current_time('timestamp')); # also covers $plugin_title_written / $plugin_description_written back-fills
1532 }
1533
1534 # A persistence-only back-fill (staging key already current, but the permanent
1535 # / SEO-plugin fields were just written) must still mark the post as updated so
1536 # the caller runs cache purge and the plugin sync. Without this, a
1537 # back-fill silently returns updated=false and the downstream refresh is skipped.
1538 $title_updated = $title_updated || $should_persist_title || $plugin_title_written;
1539 $description_updated = $description_updated || $should_persist_description || $plugin_description_written;
1540
1541 return array(
1542 'updated' => $title_updated || $description_updated,
1543 'title_updated' => $title_updated,
1544 'description_updated' => $description_updated,
1545 );
1546
1547 } catch (Exception $e) {
1548 return array(
1549 'updated' => false,
1550 'title_updated' => false,
1551 'description_updated' => false,
1552 );
1553 }
1554 }
1555
1556 /**
1557 * Update comprehensive SEO meta fields for any WordPress taxonomy (categories, WooCommerce product categories, etc.)
1558 * This is a generic function that works with any taxonomy type
1559 *
1560 * @param int $term_id Term ID
1561 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat', 'product_tag')
1562 * @param array $seo_data Complete SEO data from OTTO
1563 * @return array Success status and updated flags for all fields
1564 */
1565 function metasync_update_comprehensive_taxonomy_seo_fields($term_id, $taxonomy, $seo_data) {
1566 if (!$term_id || empty($taxonomy) || empty($seo_data)) {
1567 return array(
1568 'updated' => false,
1569 'fields_updated' => array()
1570 );
1571 }
1572
1573 # Verify the term exists
1574 $term = get_term($term_id, $taxonomy);
1575 if (is_wp_error($term) || !$term) {
1576 return array(
1577 'updated' => false,
1578 'fields_updated' => array()
1579 );
1580 }
1581
1582 $fields_updated = array();
1583 $any_updated = false;
1584
1585 try {
1586 # Check if meta descriptions are enabled in admin settings
1587 $metasync_options = get_option('metasync_options');
1588
1589 # Extract all SEO data
1590 $meta_title = metasync_extract_meta_title($seo_data);
1591 $meta_description = metasync_extract_meta_description($seo_data);
1592 $meta_keywords = metasync_extract_meta_keywords($seo_data);
1593 $og_title = metasync_extract_og_title($seo_data);
1594 $og_description = metasync_extract_og_description($seo_data);
1595 $twitter_title = metasync_extract_twitter_title($seo_data);
1596 $twitter_description = metasync_extract_twitter_description($seo_data);
1597 $image_alt_data = metasync_extract_image_alt_text($seo_data);
1598 $headings_data = metasync_extract_headings($seo_data);
1599 $structured_data = metasync_extract_structured_data($seo_data);
1600
1601 # Update basic meta fields (title and description) using the existing function
1602 $basic_result = metasync_update_taxonomy_seo_meta_fields($term_id, $taxonomy, $meta_title, $meta_description);
1603 if ($basic_result) {
1604 if ($meta_title !== null) $fields_updated['meta_title'] = $meta_title ?? '';
1605 if ($meta_description !== null) $fields_updated['meta_description'] = $meta_description ?? '';
1606 $any_updated = true;
1607 }
1608
1609 # Update meta keywords
1610 $current_keywords = get_term_meta($term_id, '_metasync_otto_keywords', true);
1611 if ($meta_keywords !== null) {
1612 if ($meta_keywords !== $current_keywords) {
1613 update_term_meta($term_id, '_metasync_otto_keywords', $meta_keywords);
1614 $fields_updated['meta_keywords'] = $meta_keywords;
1615 $any_updated = true;
1616 }
1617 } else {
1618 if (!empty($current_keywords)) {
1619 update_term_meta($term_id, '_metasync_otto_keywords', '');
1620 $fields_updated['meta_keywords'] = '';
1621 $any_updated = true;
1622 }
1623 }
1624
1625 # Update Open Graph fields
1626 $current_og_title = get_term_meta($term_id, '_metasync_otto_og_title', true);
1627 if ($og_title !== null) {
1628 if ($og_title !== $current_og_title) {
1629 update_term_meta($term_id, '_metasync_otto_og_title', $og_title);
1630 $fields_updated['og_title'] = $og_title;
1631 $any_updated = true;
1632 }
1633 } else {
1634 if (!empty($current_og_title)) {
1635 update_term_meta($term_id, '_metasync_otto_og_title', '');
1636 $fields_updated['og_title'] = '';
1637 $any_updated = true;
1638 }
1639 }
1640
1641 $current_og_desc = get_term_meta($term_id, '_metasync_otto_og_description', true);
1642 if ($og_description !== null) {
1643 if ($og_description !== $current_og_desc) {
1644 update_term_meta($term_id, '_metasync_otto_og_description', $og_description);
1645 $fields_updated['og_description'] = $og_description;
1646 $any_updated = true;
1647 }
1648 } else {
1649 if (!empty($current_og_desc)) {
1650 update_term_meta($term_id, '_metasync_otto_og_description', '');
1651 $fields_updated['og_description'] = '';
1652 $any_updated = true;
1653 }
1654 }
1655
1656 # Update Twitter fields
1657 $current_twitter_title = get_term_meta($term_id, '_metasync_otto_twitter_title', true);
1658 if ($twitter_title !== null) {
1659 if ($twitter_title !== $current_twitter_title) {
1660 update_term_meta($term_id, '_metasync_otto_twitter_title', $twitter_title);
1661 $fields_updated['twitter_title'] = $twitter_title;
1662 $any_updated = true;
1663 }
1664 } else {
1665 if (!empty($current_twitter_title)) {
1666 update_term_meta($term_id, '_metasync_otto_twitter_title', '');
1667 $fields_updated['twitter_title'] = '';
1668 $any_updated = true;
1669 }
1670 }
1671
1672 $current_twitter_desc = get_term_meta($term_id, '_metasync_otto_twitter_description', true);
1673 if ($twitter_description !== null) {
1674 if ($twitter_description !== $current_twitter_desc) {
1675 update_term_meta($term_id, '_metasync_otto_twitter_description', $twitter_description);
1676 $fields_updated['twitter_description'] = $twitter_description;
1677 $any_updated = true;
1678 }
1679 } else {
1680 if (!empty($current_twitter_desc)) {
1681 update_term_meta($term_id, '_metasync_otto_twitter_description', '');
1682 $fields_updated['twitter_description'] = '';
1683 $any_updated = true;
1684 }
1685 }
1686
1687 # Update image alt text data
1688 if (!empty($image_alt_data)) {
1689 $current_image_alt = get_term_meta($term_id, '_metasync_otto_image_alt_data', true);
1690 $image_alt_json = json_encode($image_alt_data);
1691 if ($image_alt_json !== $current_image_alt) {
1692 update_term_meta($term_id, '_metasync_otto_image_alt_data', $image_alt_json);
1693 $fields_updated['image_alt_data'] = $image_alt_data;
1694 $any_updated = true;
1695 }
1696 }
1697
1698 # Update headings data
1699 if (!empty($headings_data)) {
1700 $current_headings = get_term_meta($term_id, '_metasync_otto_headings_data', true);
1701 $headings_json = json_encode($headings_data);
1702 if ($headings_json !== $current_headings) {
1703 update_term_meta($term_id, '_metasync_otto_headings_data', $headings_json);
1704 $fields_updated['headings_data'] = $headings_data;
1705 $any_updated = true;
1706 }
1707 }
1708
1709 # Update structured data
1710 $current_structured = get_term_meta($term_id, '_metasync_otto_structured_data', true);
1711 if ($structured_data !== null) {
1712 if ($structured_data !== $current_structured) {
1713 update_term_meta($term_id, '_metasync_otto_structured_data', $structured_data);
1714 $fields_updated['structured_data'] = $structured_data;
1715 $any_updated = true;
1716 }
1717 } else {
1718 if (!empty($current_structured)) {
1719 update_term_meta($term_id, '_metasync_otto_structured_data', '');
1720 $fields_updated['structured_data'] = '';
1721 $any_updated = true;
1722 }
1723 }
1724
1725 # Store timestamp of last OTTO SEO update if any change
1726 if ($any_updated) {
1727 update_term_meta($term_id, '_metasync_otto_last_update', current_time('timestamp'));
1728 }
1729
1730 # Mirror OTTO term SEO data into the active SEO plugin(s) term storage
1731 # so category/tag archive pages render OTTO-managed values regardless
1732 # of which SEO plugin is active.
1733 if (class_exists('Metasync_Term_Plugin_Sync')) {
1734 $sync_data = array();
1735 if (!empty($meta_title)) { $sync_data['title'] = $meta_title; }
1736 if (!empty($meta_description)) { $sync_data['desc'] = $meta_description; }
1737 if (!empty($og_title)) { $sync_data['og_title'] = $og_title; }
1738 if (!empty($og_description)) { $sync_data['og_desc'] = $og_description; }
1739 if (!empty($twitter_title)) { $sync_data['twitter_title'] = $twitter_title; }
1740 if (!empty($twitter_description)) { $sync_data['twitter_desc'] = $twitter_description; }
1741
1742 if (!empty($sync_data)) {
1743 Metasync_Term_Plugin_Sync::get_instance()->sync_term($term_id, $taxonomy, $sync_data);
1744 }
1745 }
1746
1747 return array(
1748 'updated' => $any_updated,
1749 'fields_updated' => $fields_updated
1750 );
1751
1752 } catch (Exception $e) {
1753 return array(
1754 'updated' => false,
1755 'fields_updated' => array()
1756 );
1757 }
1758 }
1759
1760 /**
1761 * Resolve a URL to a taxonomy term when `url_to_postid()` reports no matching
1762 * post. This lets OTTO optimizations targeting category/tag/custom-taxonomy
1763 * archive URLs be routed to term-level persistence.
1764 *
1765 * Strategy: try each registered public taxonomy's `rewrite` slugs against each
1766 * non-empty segment of the URL path (in reverse order so the most specific
1767 * term wins), and return the first match.
1768 *
1769 * @param string $url Absolute or relative URL.
1770 * @return array|null ['term_id'=>int,'taxonomy'=>string] on a hit, null otherwise.
1771 */
1772 function metasync_resolve_url_to_term($url) {
1773 if (empty($url) || !is_string($url)) {
1774 return null;
1775 }
1776
1777 # If WordPress can resolve the URL to a post, it isn't a term archive.
1778 if (function_exists('url_to_postid')) {
1779 $post_id = url_to_postid($url);
1780 if ($post_id > 0) {
1781 return null;
1782 }
1783 }
1784
1785 $path = parse_url($url, PHP_URL_PATH);
1786 if (empty($path)) {
1787 return null;
1788 }
1789
1790 $segments = array_values(array_filter(explode('/', trim($path, '/')), 'strlen'));
1791 if (empty($segments)) {
1792 return null;
1793 }
1794
1795 $taxonomies = get_taxonomies(array('public' => true), 'names');
1796 if (empty($taxonomies)) {
1797 return null;
1798 }
1799
1800 # Walk segments from most-specific (last) to least-specific (first).
1801 for ($i = count($segments) - 1; $i >= 0; $i--) {
1802 $slug = $segments[$i];
1803 foreach ($taxonomies as $taxonomy_name) {
1804 $term = get_term_by('slug', $slug, $taxonomy_name);
1805 if ($term && !is_wp_error($term)) {
1806 return array(
1807 'term_id' => (int) $term->term_id,
1808 'taxonomy' => (string) $taxonomy_name,
1809 );
1810 }
1811 }
1812 }
1813
1814 return null;
1815 }
1816
1817 /**
1818 * Update SEO meta fields for any WordPress taxonomy (generic function)
1819 *
1820 * @param int $term_id Term ID
1821 * @param string $taxonomy Taxonomy name
1822 * @param string|null $meta_title Meta title to set
1823 * @param string|null $meta_description Meta description to set
1824 * @return bool Success status
1825 */
1826 function metasync_update_taxonomy_seo_meta_fields($term_id, $taxonomy, $meta_title, $meta_description) {
1827 if (!$term_id || empty($taxonomy)) {
1828 return false;
1829 }
1830
1831 # Verify the term exists
1832 $term = get_term($term_id, $taxonomy);
1833 if (is_wp_error($term) || !$term) {
1834 return false;
1835 }
1836
1837 $updated = false;
1838
1839 try {
1840 # Check persistence settings to determine if we should store in database
1841 # This is controlled via the OTTO Persistence API
1842 $persist_title = false;
1843 $persist_description = false;
1844
1845 if (class_exists('Metasync_Otto_Persistence_Settings')) {
1846 $persist_title = Metasync_Otto_Persistence_Settings::should_persist('meta_title');
1847 $persist_description = Metasync_Otto_Persistence_Settings::should_persist('meta_description');
1848 }
1849
1850 # If persistence is not enabled, return early (changes only applied client-side)
1851 if (!$persist_title && !$persist_description) {
1852 return true;
1853 }
1854
1855 # Compare against previously stored values
1856 $current_title = get_term_meta($term_id, '_metasync_otto_title', true);
1857 $current_description = get_term_meta($term_id, '_metasync_otto_description', true);
1858
1859 $should_update_title = ($meta_title !== null) ? ($meta_title !== $current_title) : !empty($current_title);
1860 $should_update_description = ($meta_description !== null) ? ($meta_description !== $current_description) : !empty($current_description);
1861
1862 if ($meta_title === null) $meta_title = '';
1863 if ($meta_description === null) $meta_description = '';
1864
1865 # Store to OTTO meta fields if persistence is enabled
1866 if ($should_update_title && $persist_title) {
1867 update_term_meta($term_id, '_metasync_otto_title', $meta_title);
1868 update_term_meta($term_id, 'meta_title', $meta_title);
1869 $updated = true;
1870 }
1871
1872 if ($should_update_description && $persist_description) {
1873 update_term_meta($term_id, '_metasync_otto_description', $meta_description);
1874 update_term_meta($term_id, 'meta_description', $meta_description);
1875 $updated = true;
1876 }
1877
1878 # Sync to SEO plugins if persistence enabled
1879 if ($updated) {
1880 # RankMath
1881 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
1882 if ($should_update_title && $persist_title) {
1883 update_term_meta($term_id, 'rank_math_title', $meta_title);
1884 }
1885 if ($should_update_description && $persist_description) {
1886 update_term_meta($term_id, 'rank_math_description', $meta_description);
1887 }
1888 }
1889
1890 # Yoast
1891 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
1892 if ($should_update_title && $persist_title) {
1893 update_term_meta($term_id, '_yoast_wpseo_title', $meta_title);
1894 }
1895 if ($should_update_description && $persist_description) {
1896 update_term_meta($term_id, '_yoast_wpseo_metadesc', $meta_description);
1897 }
1898 }
1899
1900 # AIOSEO
1901 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1902 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1903 if ($should_update_title && $persist_title) {
1904 update_term_meta($term_id, '_aioseo_title', $meta_title);
1905 }
1906 if ($should_update_description && $persist_description) {
1907 update_term_meta($term_id, '_aioseo_description', $meta_description);
1908 }
1909 }
1910
1911 # Store timestamp
1912 update_term_meta($term_id, '_metasync_otto_last_update', current_time('timestamp'));
1913 }
1914
1915 return true;
1916
1917 } catch (Exception $e) {
1918 return false;
1919 }
1920 }
1921
1922 /**
1923 * Update SEO meta fields for WordPress categories
1924 *
1925 * @param int $category_id Category term ID
1926 * @param string|null $meta_title Meta title to set
1927 * @param string|null $meta_description Meta description to set
1928 * @return bool Success status
1929 */
1930 function metasync_update_category_seo_meta_fields($category_id, $meta_title, $meta_description) {
1931 if (!$category_id) {
1932 return false;
1933 }
1934
1935 # Check persistence settings to determine if we should store in database
1936 # This is controlled via the OTTO Persistence API
1937 $persist_title = false;
1938 $persist_description = false;
1939
1940 if (class_exists('Metasync_Otto_Persistence_Settings')) {
1941 $persist_title = Metasync_Otto_Persistence_Settings::should_persist('meta_title');
1942 $persist_description = Metasync_Otto_Persistence_Settings::should_persist('meta_description');
1943 }
1944
1945 # If persistence is not enabled, return early (changes only applied client-side)
1946 if (!$persist_title && !$persist_description) {
1947 return true;
1948 }
1949
1950 $updated = false;
1951
1952 try {
1953 # Compare against previously stored values
1954 $current_title = get_term_meta($category_id, '_metasync_otto_title', true);
1955 $current_description = get_term_meta($category_id, '_metasync_otto_description', true);
1956
1957 $should_update_title = ($meta_title !== null) ? ($meta_title !== $current_title) : !empty($current_title);
1958 $should_update_description = ($meta_description !== null) ? ($meta_description !== $current_description) : !empty($current_description);
1959
1960 if ($meta_title === null) $meta_title = '';
1961 if ($meta_description === null) $meta_description = '';
1962
1963 # Store to OTTO meta fields if persistence is enabled
1964 if ($should_update_title && $persist_title) {
1965 update_term_meta($category_id, '_metasync_otto_title', $meta_title);
1966 $updated = true;
1967 }
1968
1969 if ($should_update_description && $persist_description) {
1970 update_term_meta($category_id, '_metasync_otto_description', $meta_description);
1971 $updated = true;
1972 }
1973
1974 # Sync to SEO plugins if persistence enabled
1975 if ($updated) {
1976 # RankMath
1977 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
1978 if ($should_update_title && $persist_title) {
1979 update_term_meta($category_id, 'rank_math_title', $meta_title);
1980 }
1981 if ($should_update_description && $persist_description) {
1982 update_term_meta($category_id, 'rank_math_description', $meta_description);
1983 }
1984 }
1985
1986 # Yoast
1987 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
1988 if ($should_update_title && $persist_title) {
1989 update_term_meta($category_id, '_yoast_wpseo_title', $meta_title);
1990 }
1991 if ($should_update_description && $persist_description) {
1992 update_term_meta($category_id, '_yoast_wpseo_metadesc', $meta_description);
1993 }
1994 }
1995
1996 # AIOSEO
1997 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1998 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1999 if ($should_update_title && $persist_title) {
2000 update_term_meta($category_id, '_aioseo_title', $meta_title);
2001 }
2002 if ($should_update_description && $persist_description) {
2003 update_term_meta($category_id, '_aioseo_description', $meta_description);
2004 }
2005 }
2006
2007 # Store timestamp
2008 update_term_meta($category_id, '_metasync_otto_last_update', current_time('timestamp'));
2009 }
2010
2011 return true;
2012
2013 } catch (Exception $e) {
2014 return false;
2015 }
2016 }
2017
2018 /**
2019 * Check if a specific plugin is active
2020 *
2021 * @param string $plugin_path Plugin path (folder/file.php)
2022 * @return bool True if plugin is active
2023 */
2024 function metasync_is_plugin_active($plugin_path) {
2025 if (!function_exists('is_plugin_active')) {
2026 include_once(ABSPATH . 'wp-admin/includes/plugin.php');
2027 }
2028
2029 return is_plugin_active($plugin_path);
2030 }
2031
2032 /**
2033 * Extract a URL string from a JSON-LD image value.
2034 *
2035 * Handles the various shapes JSON-LD images can take:
2036 * - Plain string: "https://example.com/img.jpg"
2037 * - Object with url: {"url": "https://...", "width": 800}
2038 * - Object with @id: {"@id": "#primaryimage"}
2039 * - Flat array: ["https://example.com/a.jpg", "https://example.com/b.jpg"]
2040 *
2041 * @param mixed $image The image value from JSON-LD.
2042 * @return string URL string or empty string.
2043 */
2044 function metasync_extract_jsonld_image_url( $image ) {
2045 if ( empty( $image ) ) {
2046 return '';
2047 }
2048 if ( is_string( $image ) ) {
2049 return $image;
2050 }
2051 if ( is_array( $image ) ) {
2052 if ( isset( $image['url'] ) ) {
2053 return $image['url'];
2054 }
2055 if ( isset( $image['@id'] ) ) {
2056 return $image['@id'];
2057 }
2058 if ( isset( $image[0] ) && is_string( $image[0] ) ) {
2059 return $image[0];
2060 }
2061 }
2062 return '';
2063 }
2064
2065 /**
2066 * Normalize a JSON-LD `@type` value to a single string.
2067 *
2068 * Per JSON-LD spec `@type` may be a string or an array of strings; OTTO can
2069 * send either form. Returns the first scalar element when given an array, or
2070 * an empty string when no usable scalar is present.
2071 *
2072 * @param mixed $type Raw `@type` value.
2073 * @return string Normalized type string (empty string if not derivable).
2074 */
2075 function metasync_normalize_jsonld_type( $type ) {
2076 if ( is_array( $type ) ) {
2077 $type = reset( $type );
2078 }
2079 return is_string( $type ) && '' !== $type ? $type : '';
2080 }
2081
2082 /**
2083 * Convert JSON-LD string to RankMath schema format.
2084 *
2085 * @param string $json_ld_string JSON-LD structured data string.
2086 * @return array Associative array of [$PascalCaseType => $fields_array].
2087 */
2088 function metasync_convert_jsonld_to_rankmath( $json_ld_string ) {
2089 $decoded = json_decode( $json_ld_string, true );
2090 if ( ! is_array( $decoded ) ) {
2091 return array();
2092 }
2093
2094 // Normalize to array of schema items (unwrap @graph if present).
2095 $items = array();
2096 if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
2097 $items = $decoded['@graph'];
2098 } elseif ( isset( $decoded['@type'] ) ) {
2099 $items = array( $decoded );
2100 } elseif ( isset( $decoded[0] ) ) {
2101 // Bare JSON array of schema objects (e.g. [{"@type":"Article",...}, ...]).
2102 $items = $decoded;
2103 }
2104
2105 $result = array();
2106 foreach ( $items as $item ) {
2107 if ( ! isset( $item['@type'] ) ) {
2108 continue;
2109 }
2110
2111 $type = metasync_normalize_jsonld_type( $item['@type'] );
2112 if ( '' === $type ) {
2113 continue;
2114 }
2115 $type_lower = strtolower( $type );
2116
2117 if ( 'article' === $type_lower || 'blogposting' === $type_lower || 'newsarticle' === $type_lower ) {
2118 // headline fallback: OTTO sometimes sends 'name' instead of 'headline'
2119 $headline = '';
2120 if ( ! empty( $item['headline'] ) ) {
2121 $headline = $item['headline'];
2122 } elseif ( ! empty( $item['name'] ) ) {
2123 $headline = $item['name'];
2124 }
2125 $result['Article'] = array(
2126 '@type' => 'Article',
2127 'headline' => $headline,
2128 'description' => isset( $item['description'] ) ? $item['description'] : '',
2129 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2130 'publisher' => isset( $item['publisher']['name'] ) ? $item['publisher']['name'] : ( is_string( $item['publisher'] ?? null ) ? $item['publisher'] : '' ),
2131 'publisher_logo' => isset( $item['publisher']['logo']['url'] ) ? $item['publisher']['logo']['url'] : '',
2132 );
2133 } elseif ( 'faqpage' === $type_lower ) {
2134 $questions = array();
2135 if ( isset( $item['mainEntity'] ) && is_array( $item['mainEntity'] ) ) {
2136 foreach ( $item['mainEntity'] as $question ) {
2137 if ( isset( $question['name'] ) && isset( $question['acceptedAnswer']['text'] ) ) {
2138 $questions[] = array(
2139 'name' => $question['name'],
2140 'text' => $question['acceptedAnswer']['text'],
2141 );
2142 }
2143 }
2144 }
2145 $result['FAQPage'] = array(
2146 '@type' => 'FAQPage',
2147 'questions' => $questions,
2148 );
2149 } elseif ( 'product' === $type_lower ) {
2150 $result['Product'] = array(
2151 '@type' => 'Product',
2152 'name' => isset( $item['name'] ) ? $item['name'] : '',
2153 'description' => isset( $item['description'] ) ? $item['description'] : '',
2154 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2155 'sku' => isset( $item['sku'] ) ? $item['sku'] : '',
2156 'brand' => isset( $item['brand']['name'] ) ? $item['brand']['name'] : '',
2157 'price' => isset( $item['offers']['price'] ) ? floatval( $item['offers']['price'] ) : 0,
2158 'currency' => isset( $item['offers']['priceCurrency'] ) ? $item['offers']['priceCurrency'] : 'USD',
2159 'inStock' => isset( $item['offers']['availability'] ) ? ( strpos( $item['offers']['availability'], 'InStock' ) !== false ) : true,
2160 );
2161 } elseif ( 'recipe' === $type_lower ) {
2162 $instructions = array();
2163 if ( isset( $item['recipeInstructions'] ) && is_array( $item['recipeInstructions'] ) ) {
2164 foreach ( $item['recipeInstructions'] as $step ) {
2165 if ( is_string( $step ) ) {
2166 $instructions[] = $step;
2167 } elseif ( isset( $step['text'] ) ) {
2168 $instructions[] = $step['text'];
2169 }
2170 }
2171 }
2172 $result['Recipe'] = array(
2173 '@type' => 'Recipe',
2174 'name' => isset( $item['name'] ) ? $item['name'] : '',
2175 'description' => isset( $item['description'] ) ? $item['description'] : '',
2176 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2177 'recipeYield' => isset( $item['recipeYield'] ) ? $item['recipeYield'] : '',
2178 'recipeIngredient' => isset( $item['recipeIngredient'] ) ? $item['recipeIngredient'] : array(),
2179 'recipeInstructions' => $instructions,
2180 'prepTime' => isset( $item['prepTime'] ) ? metasync_parse_iso8601_duration( $item['prepTime'] ) : 0,
2181 'cookTime' => isset( $item['cookTime'] ) ? metasync_parse_iso8601_duration( $item['cookTime'] ) : 0,
2182 'totalTime' => isset( $item['totalTime'] ) ? metasync_parse_iso8601_duration( $item['totalTime'] ) : 0,
2183 'calories' => isset( $item['nutrition']['calories'] ) ? intval( $item['nutrition']['calories'] ) : 0,
2184 );
2185 } // Unrecognized types are skipped — RankMath can't render raw JSON-LD.
2186 // They remain in metasync_schema_markup where our own renderer handles them.
2187 }
2188
2189 return $result;
2190 }
2191
2192 /**
2193 * Extract Yoast-compatible schema type strings from OTTO JSON-LD.
2194 *
2195 * Yoast does NOT store full JSON schema. It stores only type strings in post meta:
2196 * _yoast_wpseo_schema_article_type (e.g. "Article", "BlogPosting", "NewsArticle")
2197 * _yoast_wpseo_schema_page_type (e.g. "FAQPage", "CollectionPage", "ItemPage")
2198 * Yoast's own graph builder then generates the full schema from post data.
2199 *
2200 * @param string $json_ld_string JSON-LD structured data string.
2201 * @return array With 'article_type' and/or 'page_type' keys, or empty array.
2202 */
2203 function metasync_extract_yoast_schema_types( $json_ld_string ) {
2204 $decoded = json_decode( $json_ld_string, true );
2205 if ( ! is_array( $decoded ) ) {
2206 return array();
2207 }
2208
2209 // Normalize to array of items.
2210 $items = array();
2211 if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
2212 $items = $decoded['@graph'];
2213 } elseif ( isset( $decoded['@type'] ) ) {
2214 $items = array( $decoded );
2215 } elseif ( isset( $decoded[0] ) ) {
2216 $items = $decoded;
2217 }
2218
2219 // Valid Yoast article types (from Yoast's Schema_Types::ARTICLE_TYPES).
2220 $valid_article_types = array(
2221 'Article', 'BlogPosting', 'SocialMediaPosting', 'NewsArticle',
2222 'AdvertiserContentArticle', 'SatiricalArticle', 'ScholarlyArticle',
2223 'TechArticle', 'Report',
2224 );
2225
2226 // Valid Yoast page types (from Yoast's Schema_Types::PAGE_TYPES).
2227 $valid_page_types = array(
2228 'WebPage', 'ItemPage', 'AboutPage', 'FAQPage', 'QAPage', 'ProfilePage',
2229 'ContactPage', 'MedicalWebPage', 'CollectionPage', 'CheckoutPage',
2230 'RealEstateListing', 'SearchResultsPage',
2231 );
2232
2233 $result = array();
2234 foreach ( $items as $item ) {
2235 if ( ! is_array( $item ) || ! isset( $item['@type'] ) ) {
2236 continue;
2237 }
2238 $type = metasync_normalize_jsonld_type( $item['@type'] );
2239 if ( '' === $type ) {
2240 continue;
2241 }
2242
2243 if ( in_array( $type, $valid_article_types, true ) && ! isset( $result['article_type'] ) ) {
2244 $result['article_type'] = $type;
2245 } elseif ( in_array( $type, $valid_page_types, true ) && ! isset( $result['page_type'] ) ) {
2246 $result['page_type'] = $type;
2247 }
2248 }
2249
2250 return $result;
2251 }
2252
2253 /**
2254 * Convert JSON-LD string to AIOSEO graph objects for the `schema` column.
2255 *
2256 * AIOSEO v4+ stores schema in the `schema` column as a JSON object with a `graphs`
2257 * array. Each graph has `id`, `graphName`, `type`, and a `properties` object.
2258 * The `schema_type` and `schema_type_options` columns are NOT used for rendering.
2259 *
2260 * @param string $json_ld_string JSON-LD structured data string.
2261 * @return array Array of AIOSEO graph objects for the `graphs` key, or empty array.
2262 */
2263 function metasync_convert_jsonld_to_aioseo( $json_ld_string ) {
2264 $decoded = json_decode( $json_ld_string, true );
2265 if ( ! is_array( $decoded ) ) {
2266 return array();
2267 }
2268
2269 // Normalize to array of items.
2270 // OTTO can send: {"@graph":[...]}, {"@type":"..."}, or a bare array [{...},{...}].
2271 $items = array();
2272 if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
2273 $items = $decoded['@graph'];
2274 } elseif ( isset( $decoded['@type'] ) ) {
2275 $items = array( $decoded );
2276 } elseif ( isset( $decoded[0] ) ) {
2277 // Bare JSON array of schema objects (e.g. [{"@type":"ImageObject",...}, ...]).
2278 $items = $decoded;
2279 }
2280
2281 $graphs = array();
2282 foreach ( $items as $index => $item ) {
2283 if ( ! is_array( $item ) || ! isset( $item['@type'] ) ) {
2284 continue;
2285 }
2286
2287 $type = metasync_normalize_jsonld_type( $item['@type'] );
2288 if ( '' === $type ) {
2289 continue;
2290 }
2291 $type_lower = strtolower( $type );
2292 $converted = metasync_convert_jsonld_item_for_aioseo( $item, $type_lower );
2293
2294 if ( ! empty( $converted ) ) {
2295 // Recognized type with field mapping.
2296 $graphs[] = array(
2297 'id' => 'otto-schema-' . $index,
2298 'slug' => '',
2299 'graphName' => $converted['schema_type'],
2300 'label' => $converted['schema_type'],
2301 'type' => $converted['schema_type'],
2302 'properties' => $converted['options'],
2303 );
2304 } else {
2305 // Unrecognized type — pass through the raw JSON-LD properties.
2306 $properties = $item;
2307 unset( $properties['@context'], $properties['@type'] );
2308 $graphs[] = array(
2309 'id' => 'otto-schema-' . $index,
2310 'slug' => '',
2311 'graphName' => $type,
2312 'label' => $type,
2313 'type' => $type,
2314 'properties' => $properties,
2315 );
2316 }
2317 }
2318
2319 return $graphs;
2320 }
2321
2322 /**
2323 * Convert a single JSON-LD item to AIOSEO option fields.
2324 *
2325 * @param array $item Decoded JSON-LD item.
2326 * @param string $type_lower Lowercased @type value.
2327 * @return array With 'schema_type' and 'options' keys, or empty array.
2328 */
2329 function metasync_convert_jsonld_item_for_aioseo( $item, $type_lower ) {
2330 if ( 'article' === $type_lower || 'blogposting' === $type_lower || 'newsarticle' === $type_lower ) {
2331 $headline = '';
2332 if ( ! empty( $item['headline'] ) ) {
2333 $headline = $item['headline'];
2334 } elseif ( ! empty( $item['name'] ) ) {
2335 $headline = $item['name'];
2336 }
2337 return array(
2338 'schema_type' => 'Article',
2339 'options' => array(
2340 'headline' => $headline,
2341 'description' => isset( $item['description'] ) ? $item['description'] : '',
2342 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2343 'organizationName' => isset( $item['publisher']['name'] ) ? $item['publisher']['name'] : ( is_string( $item['publisher'] ?? null ) ? $item['publisher'] : '' ),
2344 'organizationLogo' => isset( $item['publisher']['logo']['url'] ) ? $item['publisher']['logo']['url'] : '',
2345 ),
2346 );
2347 } elseif ( 'faqpage' === $type_lower ) {
2348 $questions = array();
2349 if ( isset( $item['mainEntity'] ) && is_array( $item['mainEntity'] ) ) {
2350 foreach ( $item['mainEntity'] as $question ) {
2351 if ( isset( $question['name'] ) && isset( $question['acceptedAnswer']['text'] ) ) {
2352 $questions[] = array(
2353 'question' => $question['name'],
2354 'answer' => $question['acceptedAnswer']['text'],
2355 );
2356 }
2357 }
2358 }
2359 return array(
2360 'schema_type' => 'FAQPage',
2361 'options' => array( 'questions' => $questions ),
2362 );
2363 } elseif ( 'product' === $type_lower ) {
2364 return array(
2365 'schema_type' => 'Product',
2366 'options' => array(
2367 'name' => isset( $item['name'] ) ? $item['name'] : '',
2368 'description' => isset( $item['description'] ) ? $item['description'] : '',
2369 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2370 'sku' => isset( $item['sku'] ) ? $item['sku'] : '',
2371 'brand' => isset( $item['brand']['name'] ) ? $item['brand']['name'] : '',
2372 'price' => isset( $item['offers']['price'] ) ? floatval( $item['offers']['price'] ) : 0,
2373 'currency' => isset( $item['offers']['priceCurrency'] ) ? $item['offers']['priceCurrency'] : 'USD',
2374 'availability' => isset( $item['offers']['availability'] ) ? basename( $item['offers']['availability'] ) : 'InStock',
2375 'condition' => isset( $item['offers']['itemCondition'] ) ? basename( $item['offers']['itemCondition'] ) : 'NewCondition',
2376 ),
2377 );
2378 } elseif ( 'recipe' === $type_lower ) {
2379 $instructions = array();
2380 if ( isset( $item['recipeInstructions'] ) && is_array( $item['recipeInstructions'] ) ) {
2381 foreach ( $item['recipeInstructions'] as $step ) {
2382 if ( is_string( $step ) ) {
2383 $instructions[] = $step;
2384 } elseif ( isset( $step['text'] ) ) {
2385 $instructions[] = $step['text'];
2386 }
2387 }
2388 }
2389 return array(
2390 'schema_type' => 'Recipe',
2391 'options' => array(
2392 'name' => isset( $item['name'] ) ? $item['name'] : '',
2393 'description' => isset( $item['description'] ) ? $item['description'] : '',
2394 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2395 'recipeYield' => isset( $item['recipeYield'] ) ? $item['recipeYield'] : '',
2396 'recipeIngredient' => isset( $item['recipeIngredient'] ) ? $item['recipeIngredient'] : array(),
2397 'recipeInstructions' => $instructions,
2398 'prepTime' => isset( $item['prepTime'] ) ? metasync_parse_iso8601_duration( $item['prepTime'] ) : 0,
2399 'cookTime' => isset( $item['cookTime'] ) ? metasync_parse_iso8601_duration( $item['cookTime'] ) : 0,
2400 'totalTime' => isset( $item['totalTime'] ) ? metasync_parse_iso8601_duration( $item['totalTime'] ) : 0,
2401 'calories' => isset( $item['nutrition']['calories'] ) ? intval( $item['nutrition']['calories'] ) : 0,
2402 ),
2403 );
2404 }
2405
2406 return array();
2407 }
2408
2409 /**
2410 * Parse ISO 8601 duration string to minutes.
2411 * E.g., "PT15M" = 15, "PT1H30M" = 90.
2412 *
2413 * @param string $duration ISO 8601 duration string.
2414 * @return int Duration in minutes.
2415 */
2416 function metasync_parse_iso8601_duration( $duration ) {
2417 if ( empty( $duration ) ) {
2418 return 0;
2419 }
2420
2421 $minutes = 0;
2422 if ( preg_match( '/PT(\d+)H/', $duration, $hours ) ) {
2423 $minutes += intval( $hours[1] ) * 60;
2424 }
2425 if ( preg_match( '/(\d+)M/', $duration, $mins ) ) {
2426 $minutes += intval( $mins[1] );
2427 }
2428
2429 return $minutes;
2430 }
2431
2432 /**
2433 * Clear SEO-related caches after updating meta fields
2434 *
2435 * @param int $post_id WordPress post ID
2436 */
2437 function metasync_clear_post_seo_caches($post_id) {
2438 # Clear WordPress object cache for the post
2439 clean_post_cache($post_id);
2440
2441 # Clear RankMath cache if available
2442 if (class_exists('RankMath\\Helper') && method_exists('RankMath\\Helper', 'clear_cache')) {
2443 // @phpstan-ignore-next-line
2444 \RankMath\Helper::clear_cache();
2445 }
2446
2447 # Clear Yoast SEO cache if available
2448 if (class_exists('WPSEO_Utils') && method_exists('WPSEO_Utils', 'clear_cache')) {
2449 // @phpstan-ignore-next-line
2450 WPSEO_Utils::clear_cache();
2451 }
2452
2453 # Clear AIOSEO cache if available
2454 if (class_exists('AIOSEO\\Plugin\\Common\\Utils\\Cache')) {
2455 // @phpstan-ignore-next-line
2456 $cache = new \AIOSEO\Plugin\Common\Utils\Cache();
2457 $cache->clear();
2458 }
2459
2460 # Avoid global cache flush; 'clean_post_cache' already clears post-specific caches.
2461 }
2462
2463 /**
2464 * Register meta fields for REST API access (called on init)
2465 * This ensures SEO plugin meta fields are accessible via REST API
2466 * Supports WordPress posts, pages, and WooCommerce products
2467 */
2468 function metasync_register_seo_meta_fields() {
2469 # Get supported post types dynamically (includes WooCommerce products if active)
2470 $post_types = metasync_get_supported_post_types();
2471
2472 # Define all meta fields to register
2473 $meta_fields = array(
2474 # MetaSync OTTO fields
2475 '_metasync_otto_title',
2476 '_metasync_otto_description',
2477 '_metasync_otto_keywords',
2478 '_metasync_otto_og_title',
2479 '_metasync_otto_og_description',
2480 '_metasync_otto_twitter_title',
2481 '_metasync_otto_twitter_description',
2482 # RankMath fields
2483 'rank_math_title',
2484 'rank_math_description',
2485 # Yoast SEO fields
2486 '_yoast_wpseo_title',
2487 '_yoast_wpseo_metadesc',
2488 # AIOSEO fields
2489 '_aioseo_title',
2490 '_aioseo_description',
2491 );
2492
2493 # Register each meta field for each post type
2494 foreach ($post_types as $post_type) {
2495 foreach ($meta_fields as $meta_field) {
2496 register_post_meta($post_type, $meta_field, array(
2497 'type' => 'string',
2498 'single' => true,
2499 'show_in_rest' => true,
2500 'auth_callback' => function() {
2501 return current_user_can('edit_posts');
2502 }
2503 ));
2504 }
2505 }
2506
2507 # Register taxonomy meta fields for supported taxonomies
2508 $taxonomies = metasync_get_supported_taxonomies();
2509
2510 foreach ($taxonomies as $taxonomy) {
2511 foreach ($meta_fields as $meta_field) {
2512 register_term_meta($taxonomy, $meta_field, array(
2513 'type' => 'string',
2514 'single' => true,
2515 'show_in_rest' => true,
2516 'auth_callback' => function() {
2517 return current_user_can('manage_categories');
2518 }
2519 ));
2520 }
2521 }
2522 }
2523
2524 /**
2525 * Admin notice to inform about OTTO SEO integration
2526 */
2527 // function metasync_otto_seo_admin_notice() {
2528 // if (!Metasync::current_user_has_plugin_access()) {
2529 // return;
2530 // }
2531
2532 // $screen = get_current_screen();
2533 // if ($screen && $screen->id === 'plugins') {
2534 // echo '<div class="notice notice-info is-dismissible">';
2535 // echo '<p><strong>MetaSync OTTO SEO Integration:</strong> OTTO now automatically updates meta titles and descriptions for RankMath, Yoast SEO, and All in One SEO plugins when pages are crawled.</p>';
2536 // echo '</div>';
2537 // }
2538 // }
2539
2540 # Register meta fields on WordPress init
2541 add_action('init', 'metasync_register_seo_meta_fields');
2542
2543 # Show admin notice about new feature
2544 // add_action('admin_notices', 'metasync_otto_seo_admin_notice');
2545
2546 /**
2547 * Suppress OTTO-synced SEO plugin output for logged-in users.
2548 *
2549 * When "Disable OTTO for Logged in Users" is enabled, filter Yoast SEO and
2550 * Rank Math output hooks to return original (non-OTTO) values. These plugins
2551 * use internal Indexable caches, so filtering get_post_metadata is insufficient.
2552 */
2553 function metasync_is_otto_suppressed_for_current_user() {
2554 static $result = null;
2555 if ($result === null) {
2556 $result = class_exists('Metasync_Otto_Config')
2557 && Metasync_Otto_Config::is_disabled_for_loggedin()
2558 && is_user_logged_in();
2559 }
2560 return $result;
2561 }
2562
2563 /**
2564 * Check whether OTTO is disabled per-page for the current request.
2565 *
2566 * The per-page "Disable OTTO" toggle (OTTO frontend toolbar) stores a flag in
2567 * the `_metasync_otto_disabled` post meta. Historically it only gated the SSR
2568 * render path (otto/otto_pixel.php `metasync_start_otto`), so persisted OTTO
2569 * SEO values (`_metasync_otto_title` / `_metasync_otto_description`) were still
2570 * served via wp_head / the document-title filters — reading as "OTTO is
2571 * disabled but still leaking SEO".
2572 *
2573 * This helper mirrors the SSR-side check so the persisted-meta output paths can
2574 * suppress themselves too. It is post-scoped:
2575 * - Singular posts/pages: checks `is_otto_disabled(get_the_ID())`.
2576 * - WooCommerce shop page: checks `is_otto_disabled(wc_get_page_id('shop'))`.
2577 * - Archives/terms/home: the per-page toggle does not apply, so it returns
2578 * false (the toggle is post-scoped).
2579 *
2580 * @return bool True when OTTO is disabled per-page for the current request.
2581 */
2582 function metasync_is_otto_disabled_for_current_post() {
2583 # The toggle is provided by the frontend toolbar; if that class is absent
2584 # (e.g. plugin partially loaded / toolbar disabled) there is nothing to gate.
2585 if (!class_exists('Metasync_Otto_Frontend_Toolbar')) {
2586 return false;
2587 }
2588
2589 # Singular posts/pages use the current post's meta flag.
2590 if (is_singular()) {
2591 $post_id = get_the_ID();
2592 if ($post_id) {
2593 return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id);
2594 }
2595 return false;
2596 }
2597
2598 # WooCommerce shop page is backed by a real page; check its meta flag.
2599 if (function_exists('is_shop') && is_shop() && function_exists('wc_get_page_id')) {
2600 $shop_page_id = wc_get_page_id('shop');
2601 if ($shop_page_id) {
2602 return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($shop_page_id);
2603 }
2604 }
2605
2606 # Archives / terms / home — the per-page toggle is post-scoped, so do not
2607 # suppress OTTO output here.
2608 return false;
2609 }
2610
2611 # Yoast SEO filters
2612 add_filter('wpseo_title', 'metasync_suppress_yoast_title_for_loggedin', 99999);
2613 add_filter('wpseo_opengraph_title', 'metasync_suppress_yoast_title_for_loggedin', 99999);
2614 add_filter('wpseo_metadesc', 'metasync_suppress_yoast_desc_for_loggedin', 99999);
2615 add_filter('wpseo_opengraph_desc', 'metasync_suppress_yoast_desc_for_loggedin', 99999);
2616 add_filter('wpseo_schema_webpage', 'metasync_suppress_yoast_schema_for_loggedin', 99999);
2617
2618 # Rank Math filters
2619 add_filter('rank_math/frontend/title', 'metasync_suppress_yoast_title_for_loggedin', 99999);
2620 add_filter('rank_math/frontend/description', 'metasync_suppress_yoast_desc_for_loggedin', 99999);
2621
2622 function metasync_suppress_yoast_title_for_loggedin($title) {
2623 if (!metasync_is_otto_suppressed_for_current_user()) {
2624 return $title;
2625 }
2626 if (is_singular()) {
2627 $original = get_the_title();
2628 $sep = '-';
2629 if (function_exists('YoastSEO') || class_exists('WPSEO_Utils')) {
2630 $sep = apply_filters('document_title_separator', '-');
2631 }
2632 return $original . ' ' . $sep . ' ' . get_bloginfo('name');
2633 }
2634 return $title;
2635 }
2636
2637 function metasync_suppress_yoast_desc_for_loggedin($desc) {
2638 if (!metasync_is_otto_suppressed_for_current_user()) {
2639 return $desc;
2640 }
2641 # Return empty so Yoast/Rank Math falls back to auto-generated excerpt
2642 return '';
2643 }
2644
2645 function metasync_suppress_yoast_schema_for_loggedin($data) {
2646 if (!metasync_is_otto_suppressed_for_current_user()) {
2647 return $data;
2648 }
2649 if (is_singular() && isset($data['name'])) {
2650 $data['name'] = get_the_title() . ' - ' . get_bloginfo('name');
2651 }
2652 return $data;
2653 }
2654
2655 /**
2656 * Astra Theme Compatibility
2657 * Override Astra theme's title output with OTTO optimized title
2658 */
2659 function metasync_astra_title_override($title) {
2660 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
2661 return $title;
2662 }
2663
2664 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
2665 # it does not leak when OTTO is disabled for this page.
2666 if (metasync_is_otto_disabled_for_current_post()) {
2667 return $title;
2668 }
2669
2670 if (class_exists('Metasync_SEO_Conflict_Handler')) {
2671 $handler = Metasync_SEO_Conflict_Handler::get_instance();
2672 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
2673 return $title;
2674 }
2675 }
2676
2677 # Check for WooCommerce shop page
2678 if (function_exists('is_shop') && is_shop()) {
2679 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
2680 if ($shop_page_id) {
2681 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
2682 if (!empty($otto_title)) {
2683 return $otto_title;
2684 }
2685 }
2686 }
2687
2688 # Check for individual product pages
2689 if (function_exists('is_product') && is_product()) {
2690 $post_id = get_the_ID();
2691 if ($post_id) {
2692 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
2693 if (!empty($otto_title)) {
2694 return $otto_title;
2695 }
2696 }
2697 }
2698
2699 # Check for product category pages
2700 if (is_tax('product_cat')) {
2701 $term = get_queried_object();
2702 if ($term && isset($term->term_id)) {
2703 # Check if OTTO has a title for this category
2704 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2705 if (!empty($otto_title)) {
2706 return $otto_title;
2707 }
2708 }
2709 }
2710
2711 # Check for product tag pages
2712 if (is_tax('product_tag')) {
2713 $term = get_queried_object();
2714 if ($term && isset($term->term_id)) {
2715 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2716 if (!empty($otto_title)) {
2717 return $otto_title;
2718 }
2719 }
2720 }
2721
2722 return $title;
2723 }
2724
2725 # Hook into Astra's title filters (multiple hooks for maximum compatibility)
2726 add_filter('astra_the_title', 'metasync_astra_title_override', 99);
2727 add_filter('astra_wp_title', 'metasync_astra_title_override', 99);
2728
2729 /**
2730 * WooCommerce Archive Title Override
2731 * This ensures OTTO title appears on WooCommerce shop, category, and tag pages
2732 */
2733 function metasync_woocommerce_archive_title($title) {
2734 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
2735 return $title;
2736 }
2737
2738 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
2739 # it does not leak when OTTO is disabled for this page.
2740 if (metasync_is_otto_disabled_for_current_post()) {
2741 return $title;
2742 }
2743
2744 if (class_exists('Metasync_SEO_Conflict_Handler')) {
2745 $handler = Metasync_SEO_Conflict_Handler::get_instance();
2746 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
2747 return $title;
2748 }
2749 }
2750
2751 # Shop page
2752 if (function_exists('is_shop') && is_shop()) {
2753 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
2754 if ($shop_page_id) {
2755 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
2756 if (!empty($otto_title)) {
2757 return $otto_title;
2758 }
2759 }
2760 }
2761
2762 # Product categories
2763 if (is_tax('product_cat')) {
2764 $term = get_queried_object();
2765 if ($term && isset($term->term_id)) {
2766 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2767 if (!empty($otto_title)) {
2768 return $otto_title;
2769 }
2770 }
2771 }
2772
2773 # Product tags
2774 if (is_tax('product_tag')) {
2775 $term = get_queried_object();
2776 if ($term && isset($term->term_id)) {
2777 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2778 if (!empty($otto_title)) {
2779 return $otto_title;
2780 }
2781 }
2782 }
2783
2784 return $title;
2785 }
2786 add_filter('woocommerce_page_title', 'metasync_woocommerce_archive_title', 99);
2787
2788 /**
2789 * wp_title Filter (Classic WordPress themes)
2790 * Fallback for themes that use wp_title()
2791 */
2792 function metasync_wp_title_override($title, $sep = '') {
2793 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
2794 return $title;
2795 }
2796
2797 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
2798 # it does not leak when OTTO is disabled for this page.
2799 if (metasync_is_otto_disabled_for_current_post()) {
2800 return $title;
2801 }
2802
2803 if (class_exists('Metasync_SEO_Conflict_Handler')) {
2804 $handler = Metasync_SEO_Conflict_Handler::get_instance();
2805 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
2806 return $title;
2807 }
2808 }
2809
2810 # Shop page
2811 if (function_exists('is_shop') && is_shop()) {
2812 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
2813 if ($shop_page_id) {
2814 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
2815 if (!empty($otto_title)) {
2816 return $otto_title;
2817 }
2818 }
2819 }
2820
2821 # Individual product pages
2822 if (function_exists('is_product') && is_product()) {
2823 $post_id = get_the_ID();
2824 if ($post_id) {
2825 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
2826 if (!empty($otto_title)) {
2827 return $otto_title;
2828 }
2829 }
2830 }
2831
2832 # Product categories
2833 if (is_tax('product_cat')) {
2834 $term = get_queried_object();
2835 if ($term && isset($term->term_id)) {
2836 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2837 if (!empty($otto_title)) {
2838 return $otto_title;
2839 }
2840 }
2841 }
2842
2843 # Product tags
2844 if (is_tax('product_tag')) {
2845 $term = get_queried_object();
2846 if ($term && isset($term->term_id)) {
2847 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2848 if (!empty($otto_title)) {
2849 return $otto_title;
2850 }
2851 }
2852 }
2853
2854 # Regular singular posts / pages (non-WooCommerce)
2855 if (is_singular()) {
2856 $post_id = get_the_ID();
2857 if ($post_id) {
2858 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
2859 if (!empty($otto_title)) {
2860 return $otto_title;
2861 }
2862 }
2863 }
2864
2865 return $title;
2866 }
2867 add_filter('wp_title', 'metasync_wp_title_override', 99, 2);
2868
2869 /**
2870 * Document Title Parts (WordPress 4.4+)
2871 * This is the modern way WordPress handles <title> tags
2872 */
2873 function metasync_document_title_parts($title_parts) {
2874 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
2875 return $title_parts;
2876 }
2877
2878 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
2879 # it does not leak when OTTO is disabled for this page.
2880 if (metasync_is_otto_disabled_for_current_post()) {
2881 return $title_parts;
2882 }
2883
2884 if (class_exists('Metasync_SEO_Conflict_Handler')) {
2885 $handler = Metasync_SEO_Conflict_Handler::get_instance();
2886 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
2887 return $title_parts;
2888 }
2889 }
2890
2891 # Homepage (static front page backed by a real page)
2892 if (is_front_page() && !is_home()) {
2893 $front_id = (int) get_option('page_on_front');
2894 if ($front_id) {
2895 $otto_title = get_post_meta($front_id, '_metasync_otto_title', true);
2896 if (!empty($otto_title)) {
2897 $title_parts['title'] = $otto_title;
2898 unset($title_parts['tagline']);
2899 unset($title_parts['site']);
2900 }
2901 }
2902 }
2903
2904 # Shop page
2905 if (function_exists('is_shop') && is_shop()) {
2906 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
2907 if ($shop_page_id) {
2908 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
2909 if (!empty($otto_title)) {
2910 $title_parts['title'] = $otto_title;
2911 unset($title_parts['tagline']);
2912 unset($title_parts['site']);
2913 }
2914 }
2915 }
2916
2917 # Individual product pages
2918 if (function_exists('is_product') && is_product()) {
2919 $post_id = get_the_ID();
2920 if ($post_id) {
2921 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
2922 if (!empty($otto_title)) {
2923 $title_parts['title'] = $otto_title;
2924 unset($title_parts['tagline']);
2925 unset($title_parts['site']);
2926 }
2927 }
2928 }
2929
2930 # Product categories
2931 if (is_tax('product_cat')) {
2932 $term = get_queried_object();
2933 if ($term && isset($term->term_id)) {
2934 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2935 if (!empty($otto_title)) {
2936 # Replace the title part with OTTO title
2937 $title_parts['title'] = $otto_title;
2938 # Remove tagline and site name if present for cleaner title
2939 unset($title_parts['tagline']);
2940 unset($title_parts['site']);
2941 }
2942 }
2943 }
2944
2945 # Product tags
2946 if (is_tax('product_tag')) {
2947 $term = get_queried_object();
2948 if ($term && isset($term->term_id)) {
2949 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
2950 if (!empty($otto_title)) {
2951 $title_parts['title'] = $otto_title;
2952 unset($title_parts['tagline']);
2953 unset($title_parts['site']);
2954 }
2955 }
2956 }
2957
2958 # Regular singular posts / pages (non-WooCommerce)
2959 if (is_singular()) {
2960 $post_id = get_the_ID();
2961 if ($post_id) {
2962 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
2963 if (!empty($otto_title)) {
2964 $title_parts['title'] = $otto_title;
2965 unset($title_parts['tagline']);
2966 unset($title_parts['site']);
2967 }
2968 }
2969 }
2970
2971 return $title_parts;
2972 }
2973 add_filter('document_title_parts', 'metasync_document_title_parts', 99);
2974
2975 /**
2976 * Pre-get Document Title (WordPress 4.4+)
2977 * Final override before title is rendered
2978 */
2979 function metasync_pre_get_document_title($title) {
2980 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
2981 # Return empty to neutralize any SEO plugin (Yoast/RankMath) that may have
2982 # already set $title to the synced OTTO value at a lower priority.
2983 # An empty string tells WordPress to rebuild the title from default parts.
2984 return '';
2985 }
2986
2987 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
2988 # it does not leak when OTTO is disabled for this page. Return the incoming
2989 # title unchanged (do not force '') so a lower-priority SEO plugin value is
2990 # preserved — only the persisted OTTO value must be suppressed.
2991 if (metasync_is_otto_disabled_for_current_post()) {
2992 return $title;
2993 }
2994
2995 if (class_exists('Metasync_SEO_Conflict_Handler')) {
2996 $handler = Metasync_SEO_Conflict_Handler::get_instance();
2997 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
2998 return $title;
2999 }
3000 }
3001
3002 # Homepage (static front page backed by a real page)
3003 if (is_front_page() && !is_home()) {
3004 $front_id = (int) get_option('page_on_front');
3005 if ($front_id) {
3006 $otto_title = get_post_meta($front_id, '_metasync_otto_title', true);
3007 if (!empty($otto_title)) {
3008 return $otto_title;
3009 }
3010 }
3011 }
3012
3013 # Shop page
3014 if (function_exists('is_shop') && is_shop()) {
3015 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3016 if ($shop_page_id) {
3017 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
3018 if (!empty($otto_title)) {
3019 return $otto_title;
3020 }
3021 }
3022 }
3023
3024 # Individual product pages
3025 if (function_exists('is_product') && is_product()) {
3026 $post_id = get_the_ID();
3027 if ($post_id) {
3028 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3029 if (!empty($otto_title)) {
3030 return $otto_title;
3031 }
3032 }
3033 }
3034
3035 # Product categories
3036 if (is_tax('product_cat')) {
3037 $term = get_queried_object();
3038 if ($term && isset($term->term_id)) {
3039 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3040 if (!empty($otto_title)) {
3041 return $otto_title;
3042 }
3043 }
3044 }
3045
3046 # Product tags
3047 if (is_tax('product_tag')) {
3048 $term = get_queried_object();
3049 if ($term && isset($term->term_id)) {
3050 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3051 if (!empty($otto_title)) {
3052 return $otto_title;
3053 }
3054 }
3055 }
3056
3057 # Regular singular posts / pages (non-WooCommerce)
3058 if (is_singular()) {
3059 $post_id = get_the_ID();
3060 if ($post_id) {
3061 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3062 if (!empty($otto_title)) {
3063 return $otto_title;
3064 }
3065 }
3066 }
3067
3068 return $title;
3069 }
3070 add_filter('pre_get_document_title', 'metasync_pre_get_document_title', 99);
3071
3072 /**
3073 * Output OTTO meta description tag in <head> for WooCommerce pages
3074 * This is the critical function that actually renders meta descriptions to HTML
3075 * Unlike titles, WordPress doesn't automatically output meta descriptions from meta fields
3076 */
3077 function metasync_output_otto_meta_description() {
3078 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
3079 return;
3080 }
3081
3082 # Per-page "Disable OTTO" toggle: suppress persisted OTTO meta description
3083 # output so it does not leak when OTTO is disabled for this page.
3084 if (metasync_is_otto_disabled_for_current_post()) {
3085 return;
3086 }
3087
3088 // Mirror the conflict guard used in metasync_pre_get_document_title()
3089 // (around line 2817). When a third-party SEO plugin is active AND OTTO has no
3090 // live suggestions for this URL, defer to the SEO plugin's description instead
3091 // of rendering a stale _metasync_otto_description from a previous deployment.
3092 if (class_exists('Metasync_SEO_Conflict_Handler')) {
3093 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3094 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
3095 return;
3096 }
3097 }
3098
3099 // When synced to an active SEO plugin, that plugin outputs the
3100 // description from its native storage — skip OTTO's own description tag.
3101 // Only defer if the plugin's field is actually populated; a stale
3102 // or partial sync (timestamp present, plugin field empty) would otherwise
3103 // drop the description entirely, so fall through and emit OTTO's tag.
3104 if (is_singular()) {
3105 $post_id = get_the_ID();
3106 if ($post_id) {
3107 $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
3108 if (!empty($sync_ts) && class_exists('Metasync_SEO_Conflict_Handler')) {
3109 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3110 if ($handler->has_active_seo_plugin() && $handler->active_plugin_has_description($post_id)) {
3111 return;
3112 }
3113 }
3114 }
3115 }
3116
3117 // When the MetaSync SEO sidebar holds a custom description for this
3118 // post, that custom value owns the tag — Metasync_SEO_Sidebar::output_seo_meta_description()
3119 // emits it (data-metasync-seo="custom"). Skip OTTO's own description here so the
3120 // two don't both render on delivery paths where the OTTO SSR buffer dedup never
3121 // runs (cold/served-from-cache, rate-limited, or Cloudflare-pixel mode). Mirrors
3122 // the sidebar's documented "custom always wins over OTTO" precedence, and matches
3123 // deduplicate_description_tags()'s keeper order when SSR does run.
3124 if (is_singular()) {
3125 $post_id = get_the_ID();
3126 if ($post_id && !empty(get_post_meta($post_id, '_metasync_seo_desc', true))) {
3127 return;
3128 }
3129 }
3130
3131 $description = null;
3132
3133 # Shop page
3134 if (function_exists('is_shop') && is_shop()) {
3135 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3136 if ($shop_page_id) {
3137 $description = get_post_meta($shop_page_id, '_metasync_otto_description', true);
3138
3139 # Fallback to other description fields if OTTO description not found
3140 if (empty($description)) {
3141 $description = get_post_meta($shop_page_id, 'rank_math_description', true);
3142 }
3143 if (empty($description)) {
3144 $description = get_post_meta($shop_page_id, '_yoast_wpseo_metadesc', true);
3145 }
3146 if (empty($description)) {
3147 $description = get_post_meta($shop_page_id, 'meta_description', true);
3148 }
3149 }
3150 }
3151 # Individual product pages
3152 elseif (function_exists('is_product') && is_product()) {
3153 $post_id = get_the_ID();
3154 if ($post_id) {
3155 $description = get_post_meta($post_id, '_metasync_otto_description', true);
3156
3157 # Fallback to other description fields
3158 if (empty($description)) {
3159 $description = get_post_meta($post_id, 'rank_math_description', true);
3160 }
3161 if (empty($description)) {
3162 $description = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
3163 }
3164 if (empty($description)) {
3165 $description = get_post_meta($post_id, 'meta_description', true);
3166 }
3167 }
3168 }
3169 # Product categories
3170 elseif (is_tax('product_cat')) {
3171 $term = get_queried_object();
3172 if ($term && isset($term->term_id)) {
3173 $description = get_term_meta($term->term_id, '_metasync_otto_description', true);
3174
3175 # Fallback to other description fields
3176 if (empty($description)) {
3177 $description = get_term_meta($term->term_id, 'rank_math_description', true);
3178 }
3179 if (empty($description)) {
3180 $description = get_term_meta($term->term_id, '_yoast_wpseo_metadesc', true);
3181 }
3182 if (empty($description)) {
3183 $description = get_term_meta($term->term_id, 'woocommerce_meta_description', true);
3184 }
3185 if (empty($description)) {
3186 $description = get_term_meta($term->term_id, 'meta_description', true);
3187 }
3188 }
3189 }
3190 # Product tags
3191 elseif (is_tax('product_tag')) {
3192 $term = get_queried_object();
3193 if ($term && isset($term->term_id)) {
3194 $description = get_term_meta($term->term_id, '_metasync_otto_description', true);
3195
3196 # Fallback to other description fields
3197 if (empty($description)) {
3198 $description = get_term_meta($term->term_id, 'rank_math_description', true);
3199 }
3200 if (empty($description)) {
3201 $description = get_term_meta($term->term_id, '_yoast_wpseo_metadesc', true);
3202 }
3203 if (empty($description)) {
3204 $description = get_term_meta($term->term_id, 'meta_description', true);
3205 }
3206 }
3207 }
3208 # Regular singular posts / pages (non-WooCommerce)
3209 elseif (is_singular()) {
3210 $post_id = get_the_ID();
3211 if ($post_id) {
3212 $description = get_post_meta($post_id, '_metasync_otto_description', true);
3213 }
3214 }
3215
3216 # Output meta description tag if we found one
3217 if (!empty($description)) {
3218 # Escape for HTML attribute
3219 $description_escaped = esc_attr($description);
3220
3221 # Output the meta tag with OTTO marker
3222 echo '<meta name="description" content="' . $description_escaped . '" data-metasync-otto="true" />' . "\n";
3223 }
3224 }
3225 add_action('wp_head', 'metasync_output_otto_meta_description', 1);
3226