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

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