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

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