PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.6
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.6
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.6, at otto/metasync-otto-seo-functions.php

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