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

3,617 lines 159.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 $indexable_read_sql = $wpdb->prepare(
1252 "SELECT schema_article_type, schema_page_type FROM {$yoast_indexable_table} WHERE object_id = %d AND object_type = 'post'",
1253 $post_id
1254 );
1255 $current_indexable = ($indexable_read_sql === null)
1256 ? null
1257 : $wpdb->get_row($indexable_read_sql, ARRAY_A);
1258
1259 // A failed read returns the same null as "no such row",
1260 // and recording that as "the column was empty" tells a
1261 // restore to delete the customer's schema type instead
1262 // of putting it back. Record nothing at all in that
1263 // case — a backup taken now is write-once and wrong.
1264 //
1265 // A null row is refused too, for a different reason than
1266 // on the Metasync_Plugin_Sync sites: Yoast builds
1267 // indexables lazily, so a missing row here is ordinary
1268 // rather than a failure. But the only write it would
1269 // permit is an UPDATE matching no rows, and the price of
1270 // permitting it is a write-once backup saying "the column
1271 // was absent" that outlives the row Yoast creates later.
1272 //
1273 // db_read_succeeded() cannot carry this alone. wpdb::query()
1274 // returns early on `!$this->ready` and on a 'query'-filtered
1275 // empty string, both above the flush() that clears
1276 // last_error and above the line that records last_query. So
1277 // the error looks stale-clean AND last_result still holds a
1278 // previous query's rows, which get_row() would hand back as
1279 // this row. Requiring our own SQL to be the query wpdb last
1280 // ran is what rules that out.
1281 $indexable_backed_up = $indexable_read_sql !== null
1282 && $wpdb->last_query === $indexable_read_sql
1283 && $current_indexable !== null
1284 && Metasync_Seo_Backup::db_read_succeeded();
1285 $update_cols = array();
1286 $update_vals = array();
1287 if ($indexable_backed_up && !empty($yoast_types['article_type'])) {
1288 if (!Metasync_Seo_Backup::backup_before_overwrite(
1289 'post',
1290 $post_id,
1291 'yoast_indexable_schema_article_type',
1292 $yoast_types['article_type'],
1293 $current_indexable['schema_article_type'] ?? null
1294 )) {
1295 $indexable_backed_up = false;
1296 }
1297 $update_cols[] = 'schema_article_type = %s';
1298 $update_vals[] = $yoast_types['article_type'];
1299 }
1300 if ($indexable_backed_up && !empty($yoast_types['page_type'])) {
1301 if (!Metasync_Seo_Backup::backup_before_overwrite(
1302 'post',
1303 $post_id,
1304 'yoast_indexable_schema_page_type',
1305 $yoast_types['page_type'],
1306 $current_indexable['schema_page_type'] ?? null
1307 )) {
1308 $indexable_backed_up = false;
1309 }
1310 $update_cols[] = 'schema_page_type = %s';
1311 $update_vals[] = $yoast_types['page_type'];
1312 }
1313 $update_vals[] = $post_id;
1314 if ($indexable_backed_up && !empty($update_cols)) {
1315 $wpdb->query($wpdb->prepare(
1316 "UPDATE {$yoast_indexable_table} SET " . implode(', ', $update_cols) . " WHERE object_id = %d AND object_type = 'post'",
1317 $update_vals
1318 ));
1319 }
1320 }
1321 }
1322 // AIOSEO schema persistence skipped — AIOSEO Free locks Article/custom schema behind Pro paywall.
1323 // The graphs array (Pro-only) and default.graphName (UI-gated) cannot reliably render OTTO schema on Free.
1324 }
1325 }
1326
1327 # UN-DEPLOY CLEAR — OTTO sent no structured data this sync, so remove the
1328 # copy we previously persisted. Without this the stale otto_jsonld lingers in the DB
1329 # and is re-served by output_schema_markup() once OTTO is disabled (path),
1330 # making schema "survive" an un-deploy. This mirrors the _metasync_otto_structured_data
1331 # clear above and the title/description un-deploy behaviour: we only remove OUR
1332 # own otto_jsonld key and leave any user-authored per-post schema (types/enabled) intact.
1333 if (class_exists('Metasync_Otto_Persistence_Settings') && empty($structured_data)) {
1334 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1335 if (is_array($existing_schema) && array_key_exists('otto_jsonld', $existing_schema)) {
1336 unset($existing_schema['otto_jsonld']);
1337 if (empty($existing_schema)) {
1338 # Nothing but the OTTO copy was stored — drop the whole meta row rather
1339 # than leaving an empty array behind.
1340 delete_post_meta($post_id, 'metasync_schema_markup');
1341 } else {
1342 update_post_meta($post_id, 'metasync_schema_markup', wp_slash($existing_schema));
1343 }
1344 $fields_updated['structured_data_persisted'] = false;
1345 $any_updated = true;
1346 }
1347 }
1348
1349 # PERSISTENCE: canonical_url → _metasync_canonical_url + SEO plugin canonical fields
1350 if (class_exists('Metasync_Otto_Persistence_Settings') &&
1351 Metasync_Otto_Persistence_Settings::should_persist('canonical_url') &&
1352 !empty($seo_data['canonical_url'])) {
1353 # Validate before persisting: never store or mirror a corrupted /
1354 # non-URL canonical into MetaSync or SEO-plugin storage.
1355 $canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($seo_data['canonical_url']);
1356 if (!empty($canonical)) {
1357 update_post_meta($post_id, '_metasync_canonical_url', $canonical);
1358 $fields_updated['canonical_url_persisted'] = $canonical;
1359 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
1360 metasync_persist_post_field($post_id, 'rank_math_canonical_url', $canonical);
1361 }
1362 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
1363 metasync_persist_post_field($post_id, '_yoast_wpseo_canonical', $canonical);
1364 }
1365 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1366 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1367 $aioseo_field_updates['canonical_url'] = $canonical;
1368 }
1369 }
1370 }
1371
1372 # Write all collected AIOSEO field updates in a single DB call
1373 if (!empty($aioseo_field_updates)) {
1374 global $wpdb;
1375 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1376
1377 # Same guard as the block above: AIOSEO active without its tables
1378 # would let a write-once row_existed='0' marker be committed for a
1379 # table that never existed, which a restore could later read as
1380 # permission to delete a row the customer made themselves.
1381 $aioseo_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $aioseo_table));
1382 if ($aioseo_table_exists !== $aioseo_table) {
1383 $aioseo_field_updates = [];
1384 }
1385 }
1386
1387 if (!empty($aioseo_field_updates)) {
1388 global $wpdb;
1389 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1390 $aioseo_field_updates['updated'] = current_time('mysql');
1391
1392 # Read the row we are about to change so the originals can be saved
1393 # before it is overwritten, and so a restore can tell a row AIOSEO
1394 # already had from one this sync created.
1395 $aioseo_read_sql = $wpdb->prepare(
1396 "SELECT * FROM {$aioseo_table} WHERE post_id = %d", $post_id
1397 );
1398 $existing_aioseo_row = ($aioseo_read_sql === null)
1399 ? null
1400 : $wpdb->get_row($aioseo_read_sql, ARRAY_A);
1401
1402 # A failed read looks exactly like "no row" here, and the difference
1403 # is not cosmetic: the write-once row_existed marker would be
1404 # committed as '0' for a row AIOSEO really has, which a later
1405 # restore reads as licence to delete it. Leave the row untouched.
1406 #
1407 # A null row cannot be the failure signal the way it can where the
1408 # row is already known to exist. Here it is a real answer that drives
1409 # the INSERT below, and refusing it would leave every post AIOSEO has
1410 # no row for permanently unsynced. What proves the read happened is
1411 # wpdb having recorded it: query() assigns last_query only after the
1412 # early returns that leave last_error stale and last_result unflushed,
1413 # so a query of ours that never became the last query is a read that
1414 # never ran -- and get_row() may have answered from stale rows.
1415 if ($aioseo_read_sql === null
1416 || $wpdb->last_query !== $aioseo_read_sql
1417 || !Metasync_Seo_Backup::db_read_succeeded()) {
1418 $aioseo_field_updates = [];
1419 }
1420
1421 $row_exists = !empty($existing_aioseo_row);
1422 $aioseo_backups_created = [];
1423
1424 if (!empty($aioseo_field_updates)
1425 && !metasync_persist_aioseo_columns('post', $post_id, $aioseo_field_updates, $row_exists ? $existing_aioseo_row : null, $aioseo_backups_created)) {
1426 $aioseo_field_updates = [];
1427 }
1428
1429 if (empty($aioseo_field_updates)) {
1430 # Consent withheld — leave AIOSEO's row exactly as it is.
1431 } elseif ($row_exists) {
1432 $wpdb->update($aioseo_table, $aioseo_field_updates, ['post_id' => $post_id]);
1433 } else {
1434 $aioseo_field_updates['post_id'] = $post_id;
1435 $aioseo_field_updates['created'] = current_time('mysql');
1436
1437 # The row_existed='0' marker was recorded before this insert, so
1438 # a failed insert leaves a marker claiming a row that is not
1439 # there — which a restore would read as licence to delete a row
1440 # the customer creates afterwards. The per-column backups beside
1441 # it go too, or a column captured as NULL for a row that never
1442 # existed would blank a real value the customer later puts in one.
1443 if ($wpdb->insert($aioseo_table, $aioseo_field_updates) === false) {
1444 Metasync_Seo_Backup::discard_backups('post', $post_id, $aioseo_backups_created);
1445 }
1446 }
1447 }
1448
1449 // Sync to all active SEO plugins
1450 if (class_exists('Metasync_Plugin_Sync') && $any_updated) {
1451 Metasync_Plugin_Sync::get_instance()->sync_post($post_id);
1452 }
1453
1454 # Store timestamp of last OTTO SEO update if any change
1455 if ($any_updated) {
1456 update_post_meta($post_id, '_metasync_otto_last_update', current_time('timestamp'));
1457 }
1458
1459 # Keep the SEO Health summary cards in step with the table. The table
1460 # resolves live post meta while the aggregates are cached in a transient
1461 # whose save_post invalidation never fires for these direct meta writes
1462 # (crawl-notify job, MCP tools). The class files are admin-side and may
1463 # not be loaded on those paths, so pull them in on demand — they only
1464 # define classes, no side effects. The resolver is required with the
1465 # same guard the bootstrap uses, for the same partial-install reason.
1466 if ($any_updated) {
1467 if (!class_exists('Metasync_Seo_Precedence')) {
1468 $resolver_file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
1469 if (file_exists($resolver_file)) {
1470 require_once $resolver_file;
1471 }
1472 }
1473 if (!class_exists('Metasync_SEO_Health')) {
1474 $seo_health_file = dirname(__DIR__) . '/admin/class-metasync-seo-health.php';
1475 if (file_exists($seo_health_file)) {
1476 require_once $seo_health_file;
1477 }
1478 }
1479 # The method check takes parameters on purpose: a partially
1480 # updated install can pair this file with an older admin class
1481 # that lacks the method, which static analysis cannot see.
1482 if (metasync_otto_seo_class_provides('Metasync_SEO_Health', 'invalidate_stats')) {
1483 Metasync_SEO_Health::invalidate_stats();
1484 }
1485 }
1486
1487 return array(
1488 'updated' => $any_updated,
1489 'fields_updated' => $fields_updated
1490 );
1491
1492 } catch (Exception $e) {
1493 return array(
1494 'updated' => false,
1495 'fields_updated' => array()
1496 );
1497 }
1498 }
1499
1500 /**
1501 * Update comprehensive SEO meta fields for WordPress categories
1502 *
1503 * @param int $category_id Category term ID
1504 * @param array $seo_data Complete SEO data from OTTO
1505 * @return array Success status and updated flags for all fields
1506 */
1507 function metasync_update_comprehensive_category_seo_fields($category_id, $seo_data) {
1508 if (!$category_id || empty($seo_data)) {
1509 return array(
1510 'updated' => false,
1511 'fields_updated' => array()
1512 );
1513 }
1514
1515 $fields_updated = array();
1516 $any_updated = false;
1517
1518 try {
1519 # Check if meta descriptions are enabled in admin settings
1520 $metasync_options = get_option('metasync_options');
1521
1522 # Extract all SEO data
1523 $meta_title = metasync_extract_meta_title($seo_data);
1524 $meta_description = metasync_extract_meta_description($seo_data);
1525
1526 # If meta descriptions are disabled, don't process meta tags (title and description)
1527 # if ($enable_metadesc !== 'true' && $enable_metadesc !== true) {
1528 # Don't process meta title and description - return early
1529 # return array(
1530 # 'updated' => false,
1531 # 'fields_updated' => array()
1532 # );
1533 # }
1534 $meta_keywords = metasync_extract_meta_keywords($seo_data);
1535 $og_title = metasync_extract_og_title($seo_data);
1536 $og_description = metasync_extract_og_description($seo_data);
1537 $twitter_title = metasync_extract_twitter_title($seo_data);
1538 $twitter_description = metasync_extract_twitter_description($seo_data);
1539 $image_alt_data = metasync_extract_image_alt_text($seo_data);
1540 $headings_data = metasync_extract_headings($seo_data);
1541 $structured_data = metasync_extract_structured_data($seo_data);
1542
1543 # Update basic meta fields (title and description)
1544 $basic_result = metasync_update_category_seo_meta_fields($category_id, $meta_title, $meta_description);
1545 if ($basic_result) {
1546 # if ($meta_title) $fields_updated['meta_title'] = $meta_title;
1547 # if ($meta_description) $fields_updated['meta_description'] = $meta_description;
1548 if ($meta_title !== null) $fields_updated['meta_title'] = $meta_title ?? '';
1549 if ($meta_description !== null) $fields_updated['meta_description'] = $meta_description ?? '';
1550 $any_updated = true;
1551 }
1552
1553 # Update meta keywords (including empty values for rollback)
1554 if ($meta_keywords !== null) {
1555 $current_keywords = get_term_meta($category_id, '_metasync_otto_keywords', true);
1556 if ($meta_keywords !== $current_keywords) {
1557 update_term_meta($category_id, '_metasync_otto_keywords', $meta_keywords);
1558 $fields_updated['meta_keywords'] = $meta_keywords;
1559 $any_updated = true;
1560 }
1561 }
1562
1563 # Update Open Graph fields (including empty values for rollback)
1564 if ($og_title !== null) {
1565 $current_og_title = get_term_meta($category_id, '_metasync_otto_og_title', true);
1566 if ($og_title !== $current_og_title) {
1567 update_term_meta($category_id, '_metasync_otto_og_title', $og_title);
1568 $fields_updated['og_title'] = $og_title;
1569 $any_updated = true;
1570 }
1571 }
1572
1573 if ($og_description !== null) {
1574 $current_og_desc = get_term_meta($category_id, '_metasync_otto_og_description', true);
1575 if ($og_description !== $current_og_desc) {
1576 update_term_meta($category_id, '_metasync_otto_og_description', $og_description);
1577 $fields_updated['og_description'] = $og_description;
1578 $any_updated = true;
1579 }
1580 }
1581
1582 # Update Twitter fields (including empty values for rollback)
1583 if ($twitter_title !== null) {
1584 $current_twitter_title = get_term_meta($category_id, '_metasync_otto_twitter_title', true);
1585 if ($twitter_title !== $current_twitter_title) {
1586 update_term_meta($category_id, '_metasync_otto_twitter_title', $twitter_title);
1587 $fields_updated['twitter_title'] = $twitter_title;
1588 $any_updated = true;
1589 }
1590 }
1591
1592 if ($twitter_description !== null) {
1593 $current_twitter_desc = get_term_meta($category_id, '_metasync_otto_twitter_description', true);
1594 if ($twitter_description !== $current_twitter_desc) {
1595 update_term_meta($category_id, '_metasync_otto_twitter_description', $twitter_description);
1596 $fields_updated['twitter_description'] = $twitter_description;
1597 $any_updated = true;
1598 }
1599 }
1600
1601 # Update image alt text data
1602 if (!empty($image_alt_data)) {
1603 $current_image_alt = get_term_meta($category_id, '_metasync_otto_image_alt_data', true);
1604 $image_alt_json = json_encode($image_alt_data);
1605 if ($image_alt_json !== $current_image_alt) {
1606 update_term_meta($category_id, '_metasync_otto_image_alt_data', $image_alt_json);
1607 $fields_updated['image_alt_data'] = $image_alt_data;
1608 $any_updated = true;
1609 }
1610 }
1611
1612 # Update headings data
1613 if (!empty($headings_data)) {
1614 $current_headings = get_term_meta($category_id, '_metasync_otto_headings_data', true);
1615 $headings_json = json_encode($headings_data);
1616 if ($headings_json !== $current_headings) {
1617 update_term_meta($category_id, '_metasync_otto_headings_data', $headings_json);
1618 $fields_updated['headings_data'] = $headings_data;
1619 $any_updated = true;
1620 }
1621 }
1622
1623 # Update structured data (including empty values for rollback)
1624 if ($structured_data !== null) {
1625 $current_structured = get_term_meta($category_id, '_metasync_otto_structured_data', true);
1626 if ($structured_data !== $current_structured) {
1627 update_term_meta($category_id, '_metasync_otto_structured_data', $structured_data);
1628 $fields_updated['structured_data'] = $structured_data;
1629 $any_updated = true;
1630 }
1631 }
1632
1633 # Store timestamp of last OTTO SEO update if any change
1634 if ($any_updated) {
1635 update_term_meta($category_id, '_metasync_otto_last_update', current_time('timestamp'));
1636 }
1637
1638 return array(
1639 'updated' => $any_updated,
1640 'fields_updated' => $fields_updated
1641 );
1642
1643 } catch (Exception $e) {
1644 return array(
1645 'updated' => false,
1646 'fields_updated' => array()
1647 );
1648 }
1649 }
1650
1651 /**
1652 * Update SEO meta fields for WordPress and popular SEO plugins
1653 *
1654 * @param int $post_id WordPress post ID
1655 * @param string|null $meta_title Meta title to set
1656 * @param string|null $meta_description Meta description to set
1657 * @return array|false Success status and updated flags
1658 */
1659 function metasync_update_seo_meta_fields($post_id, $meta_title, $meta_description) {
1660 if (!$post_id) {
1661 return array(
1662 'updated' => false,
1663 'title_updated' => false,
1664 'description_updated' => false,
1665 );
1666 }
1667
1668 $title_updated = false;
1669 $description_updated = false;
1670
1671 try {
1672 # Meta descriptions are always enabled by default
1673
1674 # OPTIMIZED: Load post meta in single query
1675 $all_meta = get_post_custom($post_id);
1676
1677 # Compare against previously stored OTTO values to avoid unnecessary updates
1678 $current_title = $all_meta['_metasync_otto_title'][0] ?? '';
1679 $current_description = $all_meta['_metasync_otto_description'][0] ?? '';
1680
1681 # ENHANCED LOGIC: Handle clearing data when OTTO returns null (undeployed changes)
1682 # If OTTO returns null for title/description, we should clear existing OTTO data
1683 # Capture whether each field is an un-deploy "clear" (OTTO null) vs a real value.
1684 # On a clear we still wipe MetaSync's own staging/permanent fields, but must NOT
1685 # blank the SEO-plugin fields (empty meta is worse for SEO than a stale value).
1686 $title_is_clear = ($meta_title === null);
1687 $description_is_clear = ($meta_description === null);
1688
1689 $should_update_title = false;
1690 $should_update_description = false;
1691
1692 if ($meta_title !== null) {
1693 # OTTO provided a title - update if different from current
1694 $should_update_title = $meta_title !== $current_title;
1695 } else {
1696 # OTTO returned null for title - clear existing OTTO title if it exists
1697 $should_update_title = !empty($current_title);
1698 $meta_title = ''; # Set to empty string to clear the field
1699 }
1700
1701 if ($meta_description !== null) {
1702 # OTTO provided a description - update if different from current
1703 $should_update_description = $meta_description !== $current_description;
1704 } else {
1705 # OTTO returned null for description - clear existing OTTO description if it exists
1706 $should_update_description = !empty($current_description);
1707 $meta_description = ''; # Set to empty string to clear the field
1708 }
1709
1710 # Check persistence settings to determine if we should store in database
1711 # This is controlled via the OTTO Persistence API
1712 $persist_title = false;
1713 $persist_description = false;
1714
1715 if (class_exists('Metasync_Otto_Persistence_Settings')) {
1716 $persist_title = Metasync_Otto_Persistence_Settings::should_persist('meta_title');
1717 $persist_description = Metasync_Otto_Persistence_Settings::should_persist('meta_description');
1718 }
1719
1720 # Decouple the permanent/SEO-plugin write from the staging-key write.
1721 # The staging key gates on (incoming != staging); the permanent native /
1722 # SEO-plugin field must update whenever persistence is ON and the native
1723 # field is missing or differs from the incoming value. This back-fills
1724 # _metasync_metatitle / _metasync_metadesc (and SEO-plugin equivalents)
1725 # when a persistence flag is enabled after OTTO has already synced.
1726 $current_native_title = $all_meta['_metasync_metatitle'][0] ?? '';
1727 $current_native_description = $all_meta['_metasync_metadesc'][0] ?? '';
1728
1729 $should_persist_title = $persist_title && ($meta_title !== $current_native_title);
1730 $should_persist_description = $persist_description && ($meta_description !== $current_native_description);
1731
1732 # Per-plugin back-fill: gate each SEO-plugin write on that plugin's OWN
1733 # stored value, not on MetaSync's native field. Once the native field
1734 # matches the incoming value, $should_persist_* go false — but an active
1735 # plugin whose own column is empty/stale must still be back-filled. Read
1736 # each plugin's current value from the already-loaded $all_meta and write
1737 # only when persistence for that field is ON, it is not a "clear", and the
1738 # plugin's stored value differs from the incoming value. (AIOSEO lives in
1739 # the aioseo_posts table, so its current values are read inside its block.)
1740 $current_rm_title = $all_meta['rank_math_title'][0] ?? '';
1741 $current_rm_description = $all_meta['rank_math_description'][0] ?? '';
1742 $current_yoast_title = $all_meta['_yoast_wpseo_title'][0] ?? '';
1743 $current_yoast_description = $all_meta['_yoast_wpseo_metadesc'][0] ?? '';
1744
1745 $should_persist_rm_title = $persist_title && !$title_is_clear && ($meta_title !== $current_rm_title);
1746 $should_persist_rm_description = $persist_description && !$description_is_clear && ($meta_description !== $current_rm_description);
1747 $should_persist_yoast_title = $persist_title && !$title_is_clear && ($meta_title !== $current_yoast_title);
1748 $should_persist_yoast_description = $persist_description && !$description_is_clear && ($meta_description !== $current_yoast_description);
1749
1750 # Always write staging keys — these are read by OTTO's own render filters
1751 # regardless of whether persistence to native WP/3rd-party fields is enabled.
1752 if ($should_update_title) {
1753 update_post_meta($post_id, '_metasync_otto_title', $meta_title);
1754 $title_updated = true;
1755 }
1756
1757 if ($should_update_description) {
1758 update_post_meta($post_id, '_metasync_otto_description', $meta_description);
1759 $description_updated = true;
1760 }
1761
1762 # Write to plugin's own meta fields only when persistence is enabled
1763 if ($should_persist_title) {
1764 update_post_meta($post_id, '_metasync_metatitle', $meta_title);
1765 }
1766
1767 if ($should_persist_description) {
1768 update_post_meta($post_id, '_metasync_metadesc', $meta_description);
1769 }
1770
1771 # Track whether any SEO-plugin field was actually back-filled, so the
1772 # timestamp / updated roll-up below also covers plugin-only writes (the
1773 # native field may already match the incoming value while a plugin's own
1774 # column is empty or stale).
1775 $plugin_title_written = false;
1776 $plugin_description_written = false;
1777
1778 # Sync to SEO plugins whenever persistence is enabled — not only when the
1779 # native field changed. Each plugin's write is gated on that plugin's own
1780 # stored value (the $should_persist_*_* flags computed above), so an active
1781 # plugin whose column is empty/stale is back-filled even though MetaSync's
1782 # native field already matches the incoming value.
1783 if ($persist_title || $persist_description) {
1784 $effective_title = $meta_title;
1785 $effective_description = $meta_description;
1786
1787 # Update RankMath SEO plugin meta fields
1788 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
1789 if ($should_persist_rm_title && metasync_persist_post_field($post_id, 'rank_math_title', $effective_title)) {
1790 $plugin_title_written = true;
1791 }
1792 if ($should_persist_rm_description && metasync_persist_post_field($post_id, 'rank_math_description', $effective_description)) {
1793 $plugin_description_written = true;
1794 }
1795 }
1796
1797 # Update Yoast SEO plugin meta fields
1798 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
1799 if ($should_persist_yoast_title && metasync_persist_post_field($post_id, '_yoast_wpseo_title', $effective_title)) {
1800 $plugin_title_written = true;
1801 }
1802 if ($should_persist_yoast_description && metasync_persist_post_field($post_id, '_yoast_wpseo_metadesc', $effective_description)) {
1803 $plugin_description_written = true;
1804 }
1805 }
1806
1807 # Update All in One SEO plugin fields (writes to aioseo_posts table, not postmeta)
1808 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1809 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1810 global $wpdb;
1811 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1812
1813 # AIOSEO can be active before its tables exist (activation with a
1814 # pending migration). Without this guard the SELECT below returns
1815 # null, which reads as "no row", and the write-once row_existed
1816 # marker is committed as '0' against a table that was never there.
1817 # A later restore would then take that '0' as licence to delete a
1818 # row the customer created by hand. Matches the guard in
1819 # Metasync_Plugin_Sync::sync_aioseo().
1820 $aioseo_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $aioseo_table));
1821 if ($aioseo_table_exists === $aioseo_table) {
1822 # Read the plugin's own current values so the back-fill is gated on
1823 # the aioseo_posts row, not on MetaSync's native field.
1824 $aioseo_read_sql = $wpdb->prepare("SELECT title, description FROM {$aioseo_table} WHERE post_id = %d", $post_id);
1825 $current_aioseo = ($aioseo_read_sql === null)
1826 ? null
1827 : $wpdb->get_row($aioseo_read_sql);
1828
1829 # A failed read is indistinguishable from "no row" in the
1830 # value returned, and treating one as the other commits a
1831 # row_existed='0' marker for a row AIOSEO really has — which
1832 # a restore later reads as permission to delete it.
1833 #
1834 # A null row is a legitimate answer that drives the INSERT
1835 # below, so it cannot double as the failure signal. Whether
1836 # wpdb recorded this query as the one it last ran can, for
1837 # the reason given on the sibling read above.
1838 $aioseo_read_ok = $aioseo_read_sql !== null
1839 && $wpdb->last_query === $aioseo_read_sql
1840 && Metasync_Seo_Backup::db_read_succeeded();
1841
1842 $should_persist_aioseo_title = $persist_title && !$title_is_clear && ($meta_title !== ($current_aioseo->title ?? ''));
1843 $should_persist_aioseo_description = $persist_description && !$description_is_clear && ($meta_description !== ($current_aioseo->description ?? ''));
1844
1845 $aioseo_updates = [];
1846 $aioseo_backups_created = [];
1847 if ($should_persist_aioseo_title) {
1848 $aioseo_updates['title'] = $effective_title;
1849 }
1850 if ($should_persist_aioseo_description) {
1851 $aioseo_updates['description'] = $effective_description;
1852 }
1853
1854 # Only claim the fields were written once consent has actually
1855 # allowed the row through — otherwise a blocked write would still
1856 # trigger the cache purge and downstream sync below.
1857 if ($aioseo_read_ok && !empty($aioseo_updates)
1858 && metasync_persist_aioseo_columns('post', $post_id, $aioseo_updates, $current_aioseo ? (array) $current_aioseo : null, $aioseo_backups_created)) {
1859 $plugin_title_written = $plugin_title_written || $should_persist_aioseo_title;
1860 $plugin_description_written = $plugin_description_written || $should_persist_aioseo_description;
1861 $aioseo_updates['updated'] = current_time('mysql');
1862
1863 if ($current_aioseo) {
1864 $wpdb->update($aioseo_table, $aioseo_updates, ['post_id' => $post_id]);
1865 } else {
1866 $aioseo_updates['post_id'] = $post_id;
1867 $aioseo_updates['created'] = current_time('mysql');
1868
1869 # A failed insert leaves the write-once
1870 # row_existed='0' marker claiming a row we never
1871 # created, which a restore would later delete. The
1872 # per-column backups beside it go too, or a column
1873 # captured as NULL for a row that never existed
1874 # would blank a real value the customer later puts
1875 # in one.
1876 if ($wpdb->insert($aioseo_table, $aioseo_updates) === false) {
1877 Metasync_Seo_Backup::discard_backups('post', $post_id, $aioseo_backups_created);
1878 }
1879 }
1880 }
1881 }
1882 }
1883 }
1884
1885 # Store timestamp if any persistence occurred — native back-fill OR a
1886 # plugin-only back-fill. Kept outside the native-field gate so a write
1887 # that only touched a SEO plugin still records the sync timestamp.
1888 if ($should_persist_title || $should_persist_description || $plugin_title_written || $plugin_description_written) {
1889 update_post_meta($post_id, '_metasync_otto_last_update', current_time('timestamp')); # also covers $plugin_title_written / $plugin_description_written back-fills
1890 }
1891
1892 # A persistence-only back-fill (staging key already current, but the permanent
1893 # / SEO-plugin fields were just written) must still mark the post as updated so
1894 # the caller runs cache purge and the plugin sync. Without this, a
1895 # back-fill silently returns updated=false and the downstream refresh is skipped.
1896 $title_updated = $title_updated || $should_persist_title || $plugin_title_written;
1897 $description_updated = $description_updated || $should_persist_description || $plugin_description_written;
1898
1899 return array(
1900 'updated' => $title_updated || $description_updated,
1901 'title_updated' => $title_updated,
1902 'description_updated' => $description_updated,
1903 );
1904
1905 } catch (Exception $e) {
1906 return array(
1907 'updated' => false,
1908 'title_updated' => false,
1909 'description_updated' => false,
1910 );
1911 }
1912 }
1913
1914 /**
1915 * Update comprehensive SEO meta fields for any WordPress taxonomy (categories, WooCommerce product categories, etc.)
1916 * This is a generic function that works with any taxonomy type
1917 *
1918 * @param int $term_id Term ID
1919 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat', 'product_tag')
1920 * @param array $seo_data Complete SEO data from OTTO
1921 * @return array Success status and updated flags for all fields
1922 */
1923 function metasync_update_comprehensive_taxonomy_seo_fields($term_id, $taxonomy, $seo_data) {
1924 if (!$term_id || empty($taxonomy) || empty($seo_data)) {
1925 return array(
1926 'updated' => false,
1927 'fields_updated' => array()
1928 );
1929 }
1930
1931 # Verify the term exists
1932 $term = get_term($term_id, $taxonomy);
1933 if (is_wp_error($term) || !$term) {
1934 return array(
1935 'updated' => false,
1936 'fields_updated' => array()
1937 );
1938 }
1939
1940 $fields_updated = array();
1941 $any_updated = false;
1942
1943 try {
1944 # Check if meta descriptions are enabled in admin settings
1945 $metasync_options = get_option('metasync_options');
1946
1947 # Extract all SEO data
1948 $meta_title = metasync_extract_meta_title($seo_data);
1949 $meta_description = metasync_extract_meta_description($seo_data);
1950 $meta_keywords = metasync_extract_meta_keywords($seo_data);
1951 $og_title = metasync_extract_og_title($seo_data);
1952 $og_description = metasync_extract_og_description($seo_data);
1953 $twitter_title = metasync_extract_twitter_title($seo_data);
1954 $twitter_description = metasync_extract_twitter_description($seo_data);
1955 $image_alt_data = metasync_extract_image_alt_text($seo_data);
1956 $headings_data = metasync_extract_headings($seo_data);
1957 $structured_data = metasync_extract_structured_data($seo_data);
1958
1959 # Update basic meta fields (title and description) using the existing function
1960 $basic_result = metasync_update_taxonomy_seo_meta_fields($term_id, $taxonomy, $meta_title, $meta_description);
1961 if ($basic_result) {
1962 if ($meta_title !== null) $fields_updated['meta_title'] = $meta_title ?? '';
1963 if ($meta_description !== null) $fields_updated['meta_description'] = $meta_description ?? '';
1964 $any_updated = true;
1965 }
1966
1967 # Update meta keywords
1968 $current_keywords = get_term_meta($term_id, '_metasync_otto_keywords', true);
1969 if ($meta_keywords !== null) {
1970 if ($meta_keywords !== $current_keywords) {
1971 update_term_meta($term_id, '_metasync_otto_keywords', $meta_keywords);
1972 $fields_updated['meta_keywords'] = $meta_keywords;
1973 $any_updated = true;
1974 }
1975 } else {
1976 if (!empty($current_keywords)) {
1977 update_term_meta($term_id, '_metasync_otto_keywords', '');
1978 $fields_updated['meta_keywords'] = '';
1979 $any_updated = true;
1980 }
1981 }
1982
1983 # Update Open Graph fields
1984 $current_og_title = get_term_meta($term_id, '_metasync_otto_og_title', true);
1985 if ($og_title !== null) {
1986 if ($og_title !== $current_og_title) {
1987 update_term_meta($term_id, '_metasync_otto_og_title', $og_title);
1988 $fields_updated['og_title'] = $og_title;
1989 $any_updated = true;
1990 }
1991 } else {
1992 if (!empty($current_og_title)) {
1993 update_term_meta($term_id, '_metasync_otto_og_title', '');
1994 $fields_updated['og_title'] = '';
1995 $any_updated = true;
1996 }
1997 }
1998
1999 $current_og_desc = get_term_meta($term_id, '_metasync_otto_og_description', true);
2000 if ($og_description !== null) {
2001 if ($og_description !== $current_og_desc) {
2002 update_term_meta($term_id, '_metasync_otto_og_description', $og_description);
2003 $fields_updated['og_description'] = $og_description;
2004 $any_updated = true;
2005 }
2006 } else {
2007 if (!empty($current_og_desc)) {
2008 update_term_meta($term_id, '_metasync_otto_og_description', '');
2009 $fields_updated['og_description'] = '';
2010 $any_updated = true;
2011 }
2012 }
2013
2014 # Update Twitter fields
2015 $current_twitter_title = get_term_meta($term_id, '_metasync_otto_twitter_title', true);
2016 if ($twitter_title !== null) {
2017 if ($twitter_title !== $current_twitter_title) {
2018 update_term_meta($term_id, '_metasync_otto_twitter_title', $twitter_title);
2019 $fields_updated['twitter_title'] = $twitter_title;
2020 $any_updated = true;
2021 }
2022 } else {
2023 if (!empty($current_twitter_title)) {
2024 update_term_meta($term_id, '_metasync_otto_twitter_title', '');
2025 $fields_updated['twitter_title'] = '';
2026 $any_updated = true;
2027 }
2028 }
2029
2030 $current_twitter_desc = get_term_meta($term_id, '_metasync_otto_twitter_description', true);
2031 if ($twitter_description !== null) {
2032 if ($twitter_description !== $current_twitter_desc) {
2033 update_term_meta($term_id, '_metasync_otto_twitter_description', $twitter_description);
2034 $fields_updated['twitter_description'] = $twitter_description;
2035 $any_updated = true;
2036 }
2037 } else {
2038 if (!empty($current_twitter_desc)) {
2039 update_term_meta($term_id, '_metasync_otto_twitter_description', '');
2040 $fields_updated['twitter_description'] = '';
2041 $any_updated = true;
2042 }
2043 }
2044
2045 # Update image alt text data
2046 if (!empty($image_alt_data)) {
2047 $current_image_alt = get_term_meta($term_id, '_metasync_otto_image_alt_data', true);
2048 $image_alt_json = json_encode($image_alt_data);
2049 if ($image_alt_json !== $current_image_alt) {
2050 update_term_meta($term_id, '_metasync_otto_image_alt_data', $image_alt_json);
2051 $fields_updated['image_alt_data'] = $image_alt_data;
2052 $any_updated = true;
2053 }
2054 }
2055
2056 # Update headings data
2057 if (!empty($headings_data)) {
2058 $current_headings = get_term_meta($term_id, '_metasync_otto_headings_data', true);
2059 $headings_json = json_encode($headings_data);
2060 if ($headings_json !== $current_headings) {
2061 update_term_meta($term_id, '_metasync_otto_headings_data', $headings_json);
2062 $fields_updated['headings_data'] = $headings_data;
2063 $any_updated = true;
2064 }
2065 }
2066
2067 # Update structured data
2068 $current_structured = get_term_meta($term_id, '_metasync_otto_structured_data', true);
2069 if ($structured_data !== null) {
2070 if ($structured_data !== $current_structured) {
2071 update_term_meta($term_id, '_metasync_otto_structured_data', $structured_data);
2072 $fields_updated['structured_data'] = $structured_data;
2073 $any_updated = true;
2074 }
2075 } else {
2076 if (!empty($current_structured)) {
2077 update_term_meta($term_id, '_metasync_otto_structured_data', '');
2078 $fields_updated['structured_data'] = '';
2079 $any_updated = true;
2080 }
2081 }
2082
2083 # Store timestamp of last OTTO SEO update if any change
2084 if ($any_updated) {
2085 update_term_meta($term_id, '_metasync_otto_last_update', current_time('timestamp'));
2086 }
2087
2088 # Mirror OTTO term SEO data into the active SEO plugin(s) term storage
2089 # so category/tag archive pages render OTTO-managed values regardless
2090 # of which SEO plugin is active.
2091 if (class_exists('Metasync_Term_Plugin_Sync')) {
2092 $sync_data = array();
2093 if (!empty($meta_title)) { $sync_data['title'] = $meta_title; }
2094 if (!empty($meta_description)) { $sync_data['desc'] = $meta_description; }
2095 if (!empty($og_title)) { $sync_data['og_title'] = $og_title; }
2096 if (!empty($og_description)) { $sync_data['og_desc'] = $og_description; }
2097 if (!empty($twitter_title)) { $sync_data['twitter_title'] = $twitter_title; }
2098 if (!empty($twitter_description)) { $sync_data['twitter_desc'] = $twitter_description; }
2099
2100 if (!empty($sync_data)) {
2101 Metasync_Term_Plugin_Sync::get_instance()->sync_term($term_id, $taxonomy, $sync_data, true);
2102 }
2103 }
2104
2105 return array(
2106 'updated' => $any_updated,
2107 'fields_updated' => $fields_updated
2108 );
2109
2110 } catch (Exception $e) {
2111 return array(
2112 'updated' => false,
2113 'fields_updated' => array()
2114 );
2115 }
2116 }
2117
2118 /**
2119 * Resolve a URL to a taxonomy term when `url_to_postid()` reports no matching
2120 * post. This lets OTTO optimizations targeting category/tag/custom-taxonomy
2121 * archive URLs be routed to term-level persistence.
2122 *
2123 * Strategy: try each registered public taxonomy's `rewrite` slugs against each
2124 * non-empty segment of the URL path (in reverse order so the most specific
2125 * term wins), and return the first match.
2126 *
2127 * @param string $url Absolute or relative URL.
2128 * @return array|null ['term_id'=>int,'taxonomy'=>string] on a hit, null otherwise.
2129 */
2130 function metasync_resolve_url_to_term($url) {
2131 if (empty($url) || !is_string($url)) {
2132 return null;
2133 }
2134
2135 # If WordPress can resolve the URL to a post, it isn't a term archive.
2136 if (function_exists('url_to_postid')) {
2137 $post_id = url_to_postid($url);
2138 if ($post_id > 0) {
2139 return null;
2140 }
2141 }
2142
2143 $path = parse_url($url, PHP_URL_PATH);
2144 if (empty($path)) {
2145 return null;
2146 }
2147
2148 $segments = array_values(array_filter(explode('/', trim($path, '/')), 'strlen'));
2149 if (empty($segments)) {
2150 return null;
2151 }
2152
2153 $taxonomies = get_taxonomies(array('public' => true), 'names');
2154 if (empty($taxonomies)) {
2155 return null;
2156 }
2157
2158 # Walk segments from most-specific (last) to least-specific (first).
2159 for ($i = count($segments) - 1; $i >= 0; $i--) {
2160 $slug = $segments[$i];
2161 foreach ($taxonomies as $taxonomy_name) {
2162 $term = get_term_by('slug', $slug, $taxonomy_name);
2163 if ($term && !is_wp_error($term)) {
2164 return array(
2165 'term_id' => (int) $term->term_id,
2166 'taxonomy' => (string) $taxonomy_name,
2167 );
2168 }
2169 }
2170 }
2171
2172 return null;
2173 }
2174
2175 /**
2176 * Update SEO meta fields for any WordPress taxonomy (generic function)
2177 *
2178 * @param int $term_id Term ID
2179 * @param string $taxonomy Taxonomy name
2180 * @param string|null $meta_title Meta title to set
2181 * @param string|null $meta_description Meta description to set
2182 * @return bool Success status
2183 */
2184 function metasync_update_taxonomy_seo_meta_fields($term_id, $taxonomy, $meta_title, $meta_description) {
2185 if (!$term_id || empty($taxonomy)) {
2186 return false;
2187 }
2188
2189 # Verify the term exists
2190 $term = get_term($term_id, $taxonomy);
2191 if (is_wp_error($term) || !$term) {
2192 return false;
2193 }
2194
2195 $updated = false;
2196
2197 try {
2198 # Check persistence settings to determine if we should store in database
2199 # This is controlled via the OTTO Persistence API
2200 $persist_title = false;
2201 $persist_description = false;
2202
2203 if (class_exists('Metasync_Otto_Persistence_Settings')) {
2204 $persist_title = Metasync_Otto_Persistence_Settings::should_persist('meta_title');
2205 $persist_description = Metasync_Otto_Persistence_Settings::should_persist('meta_description');
2206 }
2207
2208 # If persistence is not enabled, return early (changes only applied client-side)
2209 if (!$persist_title && !$persist_description) {
2210 return true;
2211 }
2212
2213 # Compare against previously stored values
2214 $current_title = get_term_meta($term_id, '_metasync_otto_title', true);
2215 $current_description = get_term_meta($term_id, '_metasync_otto_description', true);
2216
2217 $should_update_title = ($meta_title !== null) ? ($meta_title !== $current_title) : !empty($current_title);
2218 $should_update_description = ($meta_description !== null) ? ($meta_description !== $current_description) : !empty($current_description);
2219
2220 if ($meta_title === null) $meta_title = '';
2221 if ($meta_description === null) $meta_description = '';
2222
2223 # Store to OTTO meta fields if persistence is enabled
2224 if ($should_update_title && $persist_title) {
2225 update_term_meta($term_id, '_metasync_otto_title', $meta_title);
2226 update_term_meta($term_id, 'meta_title', $meta_title);
2227 $updated = true;
2228 }
2229
2230 if ($should_update_description && $persist_description) {
2231 update_term_meta($term_id, '_metasync_otto_description', $meta_description);
2232 update_term_meta($term_id, 'meta_description', $meta_description);
2233 $updated = true;
2234 }
2235
2236 # Sync to SEO plugins if persistence enabled
2237 if ($updated) {
2238 # RankMath
2239 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
2240 if ($should_update_title && $persist_title) {
2241 metasync_persist_term_field($term_id, 'rank_math_title', $meta_title);
2242 }
2243 if ($should_update_description && $persist_description) {
2244 metasync_persist_term_field($term_id, 'rank_math_description', $meta_description);
2245 }
2246 }
2247
2248 # Yoast
2249 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
2250 if ($should_update_title && $persist_title) {
2251 metasync_persist_term_field($term_id, '_yoast_wpseo_title', $meta_title);
2252 }
2253 if ($should_update_description && $persist_description) {
2254 metasync_persist_term_field($term_id, '_yoast_wpseo_metadesc', $meta_description);
2255 }
2256 }
2257
2258 # AIOSEO
2259 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
2260 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
2261 if ($should_update_title && $persist_title) {
2262 metasync_persist_term_field($term_id, '_aioseo_title', $meta_title);
2263 }
2264 if ($should_update_description && $persist_description) {
2265 metasync_persist_term_field($term_id, '_aioseo_description', $meta_description);
2266 }
2267 }
2268
2269 # Store timestamp
2270 update_term_meta($term_id, '_metasync_otto_last_update', current_time('timestamp'));
2271 }
2272
2273 return true;
2274
2275 } catch (Exception $e) {
2276 return false;
2277 }
2278 }
2279
2280 /**
2281 * Update SEO meta fields for WordPress categories
2282 *
2283 * @param int $category_id Category term ID
2284 * @param string|null $meta_title Meta title to set
2285 * @param string|null $meta_description Meta description to set
2286 * @return bool Success status
2287 */
2288 function metasync_update_category_seo_meta_fields($category_id, $meta_title, $meta_description) {
2289 if (!$category_id) {
2290 return false;
2291 }
2292
2293 # Check persistence settings to determine if we should store in database
2294 # This is controlled via the OTTO Persistence API
2295 $persist_title = false;
2296 $persist_description = false;
2297
2298 if (class_exists('Metasync_Otto_Persistence_Settings')) {
2299 $persist_title = Metasync_Otto_Persistence_Settings::should_persist('meta_title');
2300 $persist_description = Metasync_Otto_Persistence_Settings::should_persist('meta_description');
2301 }
2302
2303 # If persistence is not enabled, return early (changes only applied client-side)
2304 if (!$persist_title && !$persist_description) {
2305 return true;
2306 }
2307
2308 $updated = false;
2309
2310 try {
2311 # Compare against previously stored values
2312 $current_title = get_term_meta($category_id, '_metasync_otto_title', true);
2313 $current_description = get_term_meta($category_id, '_metasync_otto_description', true);
2314
2315 $should_update_title = ($meta_title !== null) ? ($meta_title !== $current_title) : !empty($current_title);
2316 $should_update_description = ($meta_description !== null) ? ($meta_description !== $current_description) : !empty($current_description);
2317
2318 if ($meta_title === null) $meta_title = '';
2319 if ($meta_description === null) $meta_description = '';
2320
2321 # Store to OTTO meta fields if persistence is enabled
2322 if ($should_update_title && $persist_title) {
2323 update_term_meta($category_id, '_metasync_otto_title', $meta_title);
2324 $updated = true;
2325 }
2326
2327 if ($should_update_description && $persist_description) {
2328 update_term_meta($category_id, '_metasync_otto_description', $meta_description);
2329 $updated = true;
2330 }
2331
2332 # Sync to SEO plugins if persistence enabled
2333 if ($updated) {
2334 # RankMath
2335 if (metasync_is_plugin_active('seo-by-rank-math/rank-math.php') || metasync_is_plugin_active('seo-by-rankmath/rank-math.php')) {
2336 if ($should_update_title && $persist_title) {
2337 metasync_persist_term_field($category_id, 'rank_math_title', $meta_title);
2338 }
2339 if ($should_update_description && $persist_description) {
2340 metasync_persist_term_field($category_id, 'rank_math_description', $meta_description);
2341 }
2342 }
2343
2344 # Yoast
2345 if (metasync_is_plugin_active('wordpress-seo/wp-seo.php')) {
2346 if ($should_update_title && $persist_title) {
2347 metasync_persist_term_field($category_id, '_yoast_wpseo_title', $meta_title);
2348 }
2349 if ($should_update_description && $persist_description) {
2350 metasync_persist_term_field($category_id, '_yoast_wpseo_metadesc', $meta_description);
2351 }
2352 }
2353
2354 # AIOSEO
2355 if (metasync_is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
2356 metasync_is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
2357 if ($should_update_title && $persist_title) {
2358 metasync_persist_term_field($category_id, '_aioseo_title', $meta_title);
2359 }
2360 if ($should_update_description && $persist_description) {
2361 metasync_persist_term_field($category_id, '_aioseo_description', $meta_description);
2362 }
2363 }
2364
2365 # Store timestamp
2366 update_term_meta($category_id, '_metasync_otto_last_update', current_time('timestamp'));
2367 }
2368
2369 return true;
2370
2371 } catch (Exception $e) {
2372 return false;
2373 }
2374 }
2375
2376 /**
2377 * Check if a specific plugin is active
2378 *
2379 * @param string $plugin_path Plugin path (folder/file.php)
2380 * @return bool True if plugin is active
2381 */
2382 function metasync_is_plugin_active($plugin_path) {
2383 if (!function_exists('is_plugin_active')) {
2384 include_once(ABSPATH . 'wp-admin/includes/plugin.php');
2385 }
2386
2387 return is_plugin_active($plugin_path);
2388 }
2389
2390 /**
2391 * Extract a URL string from a JSON-LD image value.
2392 *
2393 * Handles the various shapes JSON-LD images can take:
2394 * - Plain string: "https://example.com/img.jpg"
2395 * - Object with url: {"url": "https://...", "width": 800}
2396 * - Object with @id: {"@id": "#primaryimage"}
2397 * - Flat array: ["https://example.com/a.jpg", "https://example.com/b.jpg"]
2398 *
2399 * @param mixed $image The image value from JSON-LD.
2400 * @return string URL string or empty string.
2401 */
2402 function metasync_extract_jsonld_image_url( $image ) {
2403 if ( empty( $image ) ) {
2404 return '';
2405 }
2406 if ( is_string( $image ) ) {
2407 return $image;
2408 }
2409 if ( is_array( $image ) ) {
2410 if ( isset( $image['url'] ) ) {
2411 return $image['url'];
2412 }
2413 if ( isset( $image['@id'] ) ) {
2414 return $image['@id'];
2415 }
2416 if ( isset( $image[0] ) && is_string( $image[0] ) ) {
2417 return $image[0];
2418 }
2419 }
2420 return '';
2421 }
2422
2423 /**
2424 * Normalize a JSON-LD `@type` value to a single string.
2425 *
2426 * Per JSON-LD spec `@type` may be a string or an array of strings; OTTO can
2427 * send either form. Returns the first scalar element when given an array, or
2428 * an empty string when no usable scalar is present.
2429 *
2430 * @param mixed $type Raw `@type` value.
2431 * @return string Normalized type string (empty string if not derivable).
2432 */
2433 function metasync_normalize_jsonld_type( $type ) {
2434 if ( is_array( $type ) ) {
2435 $type = reset( $type );
2436 }
2437 return is_string( $type ) && '' !== $type ? $type : '';
2438 }
2439
2440 /**
2441 * Convert JSON-LD string to RankMath schema format.
2442 *
2443 * @param string $json_ld_string JSON-LD structured data string.
2444 * @return array Associative array of [$PascalCaseType => $fields_array].
2445 */
2446 function metasync_convert_jsonld_to_rankmath( $json_ld_string ) {
2447 $decoded = json_decode( $json_ld_string, true );
2448 if ( ! is_array( $decoded ) ) {
2449 return array();
2450 }
2451
2452 // Normalize to array of schema items (unwrap @graph if present).
2453 $items = array();
2454 if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
2455 $items = $decoded['@graph'];
2456 } elseif ( isset( $decoded['@type'] ) ) {
2457 $items = array( $decoded );
2458 } elseif ( isset( $decoded[0] ) ) {
2459 // Bare JSON array of schema objects (e.g. [{"@type":"Article",...}, ...]).
2460 $items = $decoded;
2461 }
2462
2463 $result = array();
2464 foreach ( $items as $item ) {
2465 if ( ! isset( $item['@type'] ) ) {
2466 continue;
2467 }
2468
2469 $type = metasync_normalize_jsonld_type( $item['@type'] );
2470 if ( '' === $type ) {
2471 continue;
2472 }
2473 $type_lower = strtolower( $type );
2474
2475 if ( 'article' === $type_lower || 'blogposting' === $type_lower || 'newsarticle' === $type_lower ) {
2476 // headline fallback: OTTO sometimes sends 'name' instead of 'headline'
2477 $headline = '';
2478 if ( ! empty( $item['headline'] ) ) {
2479 $headline = $item['headline'];
2480 } elseif ( ! empty( $item['name'] ) ) {
2481 $headline = $item['name'];
2482 }
2483 $result['Article'] = array(
2484 '@type' => 'Article',
2485 'headline' => $headline,
2486 'description' => isset( $item['description'] ) ? $item['description'] : '',
2487 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2488 'publisher' => isset( $item['publisher']['name'] ) ? $item['publisher']['name'] : ( is_string( $item['publisher'] ?? null ) ? $item['publisher'] : '' ),
2489 'publisher_logo' => isset( $item['publisher']['logo']['url'] ) ? $item['publisher']['logo']['url'] : '',
2490 );
2491 } elseif ( 'faqpage' === $type_lower ) {
2492 $questions = array();
2493 if ( isset( $item['mainEntity'] ) && is_array( $item['mainEntity'] ) ) {
2494 foreach ( $item['mainEntity'] as $question ) {
2495 if ( isset( $question['name'] ) && isset( $question['acceptedAnswer']['text'] ) ) {
2496 $questions[] = array(
2497 'name' => $question['name'],
2498 'text' => $question['acceptedAnswer']['text'],
2499 );
2500 }
2501 }
2502 }
2503 $result['FAQPage'] = array(
2504 '@type' => 'FAQPage',
2505 'questions' => $questions,
2506 );
2507 } elseif ( 'product' === $type_lower ) {
2508 $result['Product'] = array(
2509 '@type' => 'Product',
2510 'name' => isset( $item['name'] ) ? $item['name'] : '',
2511 'description' => isset( $item['description'] ) ? $item['description'] : '',
2512 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2513 'sku' => isset( $item['sku'] ) ? $item['sku'] : '',
2514 'brand' => isset( $item['brand']['name'] ) ? $item['brand']['name'] : '',
2515 'price' => isset( $item['offers']['price'] ) ? floatval( $item['offers']['price'] ) : 0,
2516 'currency' => isset( $item['offers']['priceCurrency'] ) ? $item['offers']['priceCurrency'] : 'USD',
2517 'inStock' => isset( $item['offers']['availability'] ) ? ( strpos( $item['offers']['availability'], 'InStock' ) !== false ) : true,
2518 );
2519 } elseif ( 'recipe' === $type_lower ) {
2520 $instructions = array();
2521 if ( isset( $item['recipeInstructions'] ) && is_array( $item['recipeInstructions'] ) ) {
2522 foreach ( $item['recipeInstructions'] as $step ) {
2523 if ( is_string( $step ) ) {
2524 $instructions[] = $step;
2525 } elseif ( isset( $step['text'] ) ) {
2526 $instructions[] = $step['text'];
2527 }
2528 }
2529 }
2530 $result['Recipe'] = array(
2531 '@type' => 'Recipe',
2532 'name' => isset( $item['name'] ) ? $item['name'] : '',
2533 'description' => isset( $item['description'] ) ? $item['description'] : '',
2534 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2535 'recipeYield' => isset( $item['recipeYield'] ) ? $item['recipeYield'] : '',
2536 'recipeIngredient' => isset( $item['recipeIngredient'] ) ? $item['recipeIngredient'] : array(),
2537 'recipeInstructions' => $instructions,
2538 'prepTime' => isset( $item['prepTime'] ) ? metasync_parse_iso8601_duration( $item['prepTime'] ) : 0,
2539 'cookTime' => isset( $item['cookTime'] ) ? metasync_parse_iso8601_duration( $item['cookTime'] ) : 0,
2540 'totalTime' => isset( $item['totalTime'] ) ? metasync_parse_iso8601_duration( $item['totalTime'] ) : 0,
2541 'calories' => isset( $item['nutrition']['calories'] ) ? intval( $item['nutrition']['calories'] ) : 0,
2542 );
2543 } // Unrecognized types are skipped — RankMath can't render raw JSON-LD.
2544 // They remain in metasync_schema_markup where our own renderer handles them.
2545 }
2546
2547 return $result;
2548 }
2549
2550 /**
2551 * Extract Yoast-compatible schema type strings from OTTO JSON-LD.
2552 *
2553 * Yoast does NOT store full JSON schema. It stores only type strings in post meta:
2554 * _yoast_wpseo_schema_article_type (e.g. "Article", "BlogPosting", "NewsArticle")
2555 * _yoast_wpseo_schema_page_type (e.g. "FAQPage", "CollectionPage", "ItemPage")
2556 * Yoast's own graph builder then generates the full schema from post data.
2557 *
2558 * @param string $json_ld_string JSON-LD structured data string.
2559 * @return array With 'article_type' and/or 'page_type' keys, or empty array.
2560 */
2561 function metasync_extract_yoast_schema_types( $json_ld_string ) {
2562 $decoded = json_decode( $json_ld_string, true );
2563 if ( ! is_array( $decoded ) ) {
2564 return array();
2565 }
2566
2567 // Normalize to array of items.
2568 $items = array();
2569 if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
2570 $items = $decoded['@graph'];
2571 } elseif ( isset( $decoded['@type'] ) ) {
2572 $items = array( $decoded );
2573 } elseif ( isset( $decoded[0] ) ) {
2574 $items = $decoded;
2575 }
2576
2577 // Valid Yoast article types (from Yoast's Schema_Types::ARTICLE_TYPES).
2578 $valid_article_types = array(
2579 'Article', 'BlogPosting', 'SocialMediaPosting', 'NewsArticle',
2580 'AdvertiserContentArticle', 'SatiricalArticle', 'ScholarlyArticle',
2581 'TechArticle', 'Report',
2582 );
2583
2584 // Valid Yoast page types (from Yoast's Schema_Types::PAGE_TYPES).
2585 $valid_page_types = array(
2586 'WebPage', 'ItemPage', 'AboutPage', 'FAQPage', 'QAPage', 'ProfilePage',
2587 'ContactPage', 'MedicalWebPage', 'CollectionPage', 'CheckoutPage',
2588 'RealEstateListing', 'SearchResultsPage',
2589 );
2590
2591 $result = array();
2592 foreach ( $items as $item ) {
2593 if ( ! is_array( $item ) || ! isset( $item['@type'] ) ) {
2594 continue;
2595 }
2596 $type = metasync_normalize_jsonld_type( $item['@type'] );
2597 if ( '' === $type ) {
2598 continue;
2599 }
2600
2601 if ( in_array( $type, $valid_article_types, true ) && ! isset( $result['article_type'] ) ) {
2602 $result['article_type'] = $type;
2603 } elseif ( in_array( $type, $valid_page_types, true ) && ! isset( $result['page_type'] ) ) {
2604 $result['page_type'] = $type;
2605 }
2606 }
2607
2608 return $result;
2609 }
2610
2611 /**
2612 * Convert JSON-LD string to AIOSEO graph objects for the `schema` column.
2613 *
2614 * AIOSEO v4+ stores schema in the `schema` column as a JSON object with a `graphs`
2615 * array. Each graph has `id`, `graphName`, `type`, and a `properties` object.
2616 * The `schema_type` and `schema_type_options` columns are NOT used for rendering.
2617 *
2618 * @param string $json_ld_string JSON-LD structured data string.
2619 * @return array Array of AIOSEO graph objects for the `graphs` key, or empty array.
2620 */
2621 function metasync_convert_jsonld_to_aioseo( $json_ld_string ) {
2622 $decoded = json_decode( $json_ld_string, true );
2623 if ( ! is_array( $decoded ) ) {
2624 return array();
2625 }
2626
2627 // Normalize to array of items.
2628 // OTTO can send: {"@graph":[...]}, {"@type":"..."}, or a bare array [{...},{...}].
2629 $items = array();
2630 if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
2631 $items = $decoded['@graph'];
2632 } elseif ( isset( $decoded['@type'] ) ) {
2633 $items = array( $decoded );
2634 } elseif ( isset( $decoded[0] ) ) {
2635 // Bare JSON array of schema objects (e.g. [{"@type":"ImageObject",...}, ...]).
2636 $items = $decoded;
2637 }
2638
2639 $graphs = array();
2640 foreach ( $items as $index => $item ) {
2641 if ( ! is_array( $item ) || ! isset( $item['@type'] ) ) {
2642 continue;
2643 }
2644
2645 $type = metasync_normalize_jsonld_type( $item['@type'] );
2646 if ( '' === $type ) {
2647 continue;
2648 }
2649 $type_lower = strtolower( $type );
2650 $converted = metasync_convert_jsonld_item_for_aioseo( $item, $type_lower );
2651
2652 if ( ! empty( $converted ) ) {
2653 // Recognized type with field mapping.
2654 $graphs[] = array(
2655 'id' => 'otto-schema-' . $index,
2656 'slug' => '',
2657 'graphName' => $converted['schema_type'],
2658 'label' => $converted['schema_type'],
2659 'type' => $converted['schema_type'],
2660 'properties' => $converted['options'],
2661 );
2662 } else {
2663 // Unrecognized type — pass through the raw JSON-LD properties.
2664 $properties = $item;
2665 unset( $properties['@context'], $properties['@type'] );
2666 $graphs[] = array(
2667 'id' => 'otto-schema-' . $index,
2668 'slug' => '',
2669 'graphName' => $type,
2670 'label' => $type,
2671 'type' => $type,
2672 'properties' => $properties,
2673 );
2674 }
2675 }
2676
2677 return $graphs;
2678 }
2679
2680 /**
2681 * Convert a single JSON-LD item to AIOSEO option fields.
2682 *
2683 * @param array $item Decoded JSON-LD item.
2684 * @param string $type_lower Lowercased @type value.
2685 * @return array With 'schema_type' and 'options' keys, or empty array.
2686 */
2687 function metasync_convert_jsonld_item_for_aioseo( $item, $type_lower ) {
2688 if ( 'article' === $type_lower || 'blogposting' === $type_lower || 'newsarticle' === $type_lower ) {
2689 $headline = '';
2690 if ( ! empty( $item['headline'] ) ) {
2691 $headline = $item['headline'];
2692 } elseif ( ! empty( $item['name'] ) ) {
2693 $headline = $item['name'];
2694 }
2695 return array(
2696 'schema_type' => 'Article',
2697 'options' => array(
2698 'headline' => $headline,
2699 'description' => isset( $item['description'] ) ? $item['description'] : '',
2700 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2701 'organizationName' => isset( $item['publisher']['name'] ) ? $item['publisher']['name'] : ( is_string( $item['publisher'] ?? null ) ? $item['publisher'] : '' ),
2702 'organizationLogo' => isset( $item['publisher']['logo']['url'] ) ? $item['publisher']['logo']['url'] : '',
2703 ),
2704 );
2705 } elseif ( 'faqpage' === $type_lower ) {
2706 $questions = array();
2707 if ( isset( $item['mainEntity'] ) && is_array( $item['mainEntity'] ) ) {
2708 foreach ( $item['mainEntity'] as $question ) {
2709 if ( isset( $question['name'] ) && isset( $question['acceptedAnswer']['text'] ) ) {
2710 $questions[] = array(
2711 'question' => $question['name'],
2712 'answer' => $question['acceptedAnswer']['text'],
2713 );
2714 }
2715 }
2716 }
2717 return array(
2718 'schema_type' => 'FAQPage',
2719 'options' => array( 'questions' => $questions ),
2720 );
2721 } elseif ( 'product' === $type_lower ) {
2722 return array(
2723 'schema_type' => 'Product',
2724 'options' => array(
2725 'name' => isset( $item['name'] ) ? $item['name'] : '',
2726 'description' => isset( $item['description'] ) ? $item['description'] : '',
2727 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2728 'sku' => isset( $item['sku'] ) ? $item['sku'] : '',
2729 'brand' => isset( $item['brand']['name'] ) ? $item['brand']['name'] : '',
2730 'price' => isset( $item['offers']['price'] ) ? floatval( $item['offers']['price'] ) : 0,
2731 'currency' => isset( $item['offers']['priceCurrency'] ) ? $item['offers']['priceCurrency'] : 'USD',
2732 'availability' => isset( $item['offers']['availability'] ) ? basename( $item['offers']['availability'] ) : 'InStock',
2733 'condition' => isset( $item['offers']['itemCondition'] ) ? basename( $item['offers']['itemCondition'] ) : 'NewCondition',
2734 ),
2735 );
2736 } elseif ( 'recipe' === $type_lower ) {
2737 $instructions = array();
2738 if ( isset( $item['recipeInstructions'] ) && is_array( $item['recipeInstructions'] ) ) {
2739 foreach ( $item['recipeInstructions'] as $step ) {
2740 if ( is_string( $step ) ) {
2741 $instructions[] = $step;
2742 } elseif ( isset( $step['text'] ) ) {
2743 $instructions[] = $step['text'];
2744 }
2745 }
2746 }
2747 return array(
2748 'schema_type' => 'Recipe',
2749 'options' => array(
2750 'name' => isset( $item['name'] ) ? $item['name'] : '',
2751 'description' => isset( $item['description'] ) ? $item['description'] : '',
2752 'image' => metasync_extract_jsonld_image_url( isset( $item['image'] ) ? $item['image'] : null ),
2753 'recipeYield' => isset( $item['recipeYield'] ) ? $item['recipeYield'] : '',
2754 'recipeIngredient' => isset( $item['recipeIngredient'] ) ? $item['recipeIngredient'] : array(),
2755 'recipeInstructions' => $instructions,
2756 'prepTime' => isset( $item['prepTime'] ) ? metasync_parse_iso8601_duration( $item['prepTime'] ) : 0,
2757 'cookTime' => isset( $item['cookTime'] ) ? metasync_parse_iso8601_duration( $item['cookTime'] ) : 0,
2758 'totalTime' => isset( $item['totalTime'] ) ? metasync_parse_iso8601_duration( $item['totalTime'] ) : 0,
2759 'calories' => isset( $item['nutrition']['calories'] ) ? intval( $item['nutrition']['calories'] ) : 0,
2760 ),
2761 );
2762 }
2763
2764 return array();
2765 }
2766
2767 /**
2768 * Parse ISO 8601 duration string to minutes.
2769 * E.g., "PT15M" = 15, "PT1H30M" = 90.
2770 *
2771 * @param string $duration ISO 8601 duration string.
2772 * @return int Duration in minutes.
2773 */
2774 function metasync_parse_iso8601_duration( $duration ) {
2775 if ( empty( $duration ) ) {
2776 return 0;
2777 }
2778
2779 $minutes = 0;
2780 if ( preg_match( '/PT(\d+)H/', $duration, $hours ) ) {
2781 $minutes += intval( $hours[1] ) * 60;
2782 }
2783 if ( preg_match( '/(\d+)M/', $duration, $mins ) ) {
2784 $minutes += intval( $mins[1] );
2785 }
2786
2787 return $minutes;
2788 }
2789
2790 /**
2791 * Clear SEO-related caches after updating meta fields
2792 *
2793 * @param int $post_id WordPress post ID
2794 */
2795 function metasync_clear_post_seo_caches($post_id) {
2796 # Clear WordPress object cache for the post
2797 clean_post_cache($post_id);
2798
2799 # Clear RankMath cache if available
2800 if (class_exists('RankMath\\Helper') && method_exists('RankMath\\Helper', 'clear_cache')) {
2801 // @phpstan-ignore-next-line
2802 \RankMath\Helper::clear_cache();
2803 }
2804
2805 # Clear Yoast SEO cache if available
2806 if (class_exists('WPSEO_Utils') && method_exists('WPSEO_Utils', 'clear_cache')) {
2807 // @phpstan-ignore-next-line
2808 WPSEO_Utils::clear_cache();
2809 }
2810
2811 # Clear AIOSEO cache if available
2812 if (class_exists('AIOSEO\\Plugin\\Common\\Utils\\Cache')) {
2813 // @phpstan-ignore-next-line
2814 $cache = new \AIOSEO\Plugin\Common\Utils\Cache();
2815 $cache->clear();
2816 }
2817
2818 # Avoid global cache flush; 'clean_post_cache' already clears post-specific caches.
2819 }
2820
2821 /**
2822 * Register meta fields for REST API access (called on init)
2823 * This ensures SEO plugin meta fields are accessible via REST API
2824 * Supports WordPress posts, pages, and WooCommerce products
2825 */
2826 function metasync_register_seo_meta_fields() {
2827 # Get supported post types dynamically (includes WooCommerce products if active)
2828 $post_types = metasync_get_supported_post_types();
2829
2830 # Define all meta fields to register
2831 $meta_fields = array(
2832 # MetaSync OTTO fields
2833 '_metasync_otto_title',
2834 '_metasync_otto_description',
2835 '_metasync_otto_keywords',
2836 '_metasync_otto_og_title',
2837 '_metasync_otto_og_description',
2838 '_metasync_otto_twitter_title',
2839 '_metasync_otto_twitter_description',
2840 # Third-party SEO fields are intentionally not registered here. They
2841 # are customer-owned data and exposing them through the generic REST
2842 # meta endpoint lets low-privilege users overwrite them without going
2843 # through the consent/backup gate. The SEO plugins own their APIs.
2844 );
2845
2846 # Register each meta field for each post type
2847 foreach ($post_types as $post_type) {
2848 foreach ($meta_fields as $meta_field) {
2849 register_post_meta($post_type, $meta_field, array(
2850 'type' => 'string',
2851 'single' => true,
2852 'show_in_rest' => true,
2853 'auth_callback' => function() {
2854 return current_user_can('edit_posts');
2855 }
2856 ));
2857 }
2858 }
2859
2860 # Register taxonomy meta fields for supported taxonomies
2861 $taxonomies = metasync_get_supported_taxonomies();
2862
2863 foreach ($taxonomies as $taxonomy) {
2864 foreach ($meta_fields as $meta_field) {
2865 register_term_meta($taxonomy, $meta_field, array(
2866 'type' => 'string',
2867 'single' => true,
2868 'show_in_rest' => true,
2869 'auth_callback' => function() {
2870 return current_user_can('manage_categories');
2871 }
2872 ));
2873 }
2874 }
2875 }
2876
2877 /**
2878 * Admin notice to inform about OTTO SEO integration
2879 */
2880 // function metasync_otto_seo_admin_notice() {
2881 // if (!Metasync::current_user_has_plugin_access()) {
2882 // return;
2883 // }
2884
2885 // $screen = get_current_screen();
2886 // if ($screen && $screen->id === 'plugins') {
2887 // echo '<div class="notice notice-info is-dismissible">';
2888 // 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>';
2889 // echo '</div>';
2890 // }
2891 // }
2892
2893 # Register meta fields on WordPress init
2894 add_action('init', 'metasync_register_seo_meta_fields');
2895
2896 # Show admin notice about new feature
2897 // add_action('admin_notices', 'metasync_otto_seo_admin_notice');
2898
2899 /**
2900 * Suppress OTTO-synced SEO plugin output for logged-in users.
2901 *
2902 * When "Disable OTTO for Logged in Users" is enabled, filter Yoast SEO and
2903 * Rank Math output hooks to return original (non-OTTO) values. These plugins
2904 * use internal Indexable caches, so filtering get_post_metadata is insufficient.
2905 */
2906 function metasync_is_otto_suppressed_for_current_user() {
2907 static $result = null;
2908 if ($result === null) {
2909 $result = class_exists('Metasync_Otto_Config')
2910 && Metasync_Otto_Config::is_disabled_for_loggedin()
2911 && is_user_logged_in();
2912 }
2913 return $result;
2914 }
2915
2916 /**
2917 * Check whether OTTO is disabled per-page for the current request.
2918 *
2919 * The per-page "Disable OTTO" toggle (OTTO frontend toolbar) stores a flag in
2920 * the `_metasync_otto_disabled` post meta. Historically it only gated the SSR
2921 * render path (otto/otto_pixel.php `metasync_start_otto`), so persisted OTTO
2922 * SEO values (`_metasync_otto_title` / `_metasync_otto_description`) were still
2923 * served via wp_head / the document-title filters — reading as "OTTO is
2924 * disabled but still leaking SEO".
2925 *
2926 * This helper mirrors the SSR-side check so the persisted-meta output paths can
2927 * suppress themselves too. It is post-scoped:
2928 * - Singular posts/pages: checks `is_otto_disabled(get_the_ID())`.
2929 * - WooCommerce shop page: checks `is_otto_disabled(wc_get_page_id('shop'))`.
2930 * - Archives/terms/home: the per-page toggle does not apply, so it returns
2931 * false (the toggle is post-scoped).
2932 *
2933 * @return bool True when OTTO is disabled per-page for the current request.
2934 */
2935 function metasync_is_otto_disabled_for_current_post() {
2936 # The toggle is provided by the frontend toolbar; if that class is absent
2937 # (e.g. plugin partially loaded / toolbar disabled) there is nothing to gate.
2938 if (!class_exists('Metasync_Otto_Frontend_Toolbar')) {
2939 return false;
2940 }
2941
2942 # Singular posts/pages use the current post's meta flag.
2943 if (is_singular()) {
2944 $post_id = get_the_ID();
2945 if ($post_id) {
2946 return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id);
2947 }
2948 return false;
2949 }
2950
2951 # WooCommerce shop page is backed by a real page; check its meta flag.
2952 if (function_exists('is_shop') && is_shop() && function_exists('wc_get_page_id')) {
2953 $shop_page_id = wc_get_page_id('shop');
2954 if ($shop_page_id) {
2955 return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($shop_page_id);
2956 }
2957 }
2958
2959 # Archives / terms / home — the per-page toggle is post-scoped, so do not
2960 # suppress OTTO output here.
2961 return false;
2962 }
2963
2964 # Yoast SEO filters
2965 #
2966 # The plain title/description filters belong to the SEO title/description
2967 # feature, which has its own setting. The og:* and schema filters answer to the
2968 # Social Media & Open Graph and Schema Markup switches: with either off MetaSync
2969 # emits nothing of that kind, so blanking Yoast's value would leave the page
2970 # with none. metasync_otto_social_output_enabled() lives in otto_pixel.php.
2971 add_filter('wpseo_title', 'metasync_suppress_yoast_title_for_loggedin', 99999);
2972 add_filter('wpseo_metadesc', 'metasync_suppress_yoast_desc_for_loggedin', 99999);
2973
2974 if (!function_exists('metasync_otto_social_output_enabled')
2975 || metasync_otto_social_output_enabled()) {
2976 add_filter('wpseo_opengraph_title', 'metasync_suppress_yoast_title_for_loggedin', 99999);
2977 add_filter('wpseo_opengraph_desc', 'metasync_suppress_yoast_desc_for_loggedin', 99999);
2978 }
2979
2980 if (!class_exists('Metasync_Feature_Flags')
2981 || Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SCHEMA)) {
2982 add_filter('wpseo_schema_webpage', 'metasync_suppress_yoast_schema_for_loggedin', 99999);
2983 }
2984
2985 # Rank Math filters
2986 add_filter('rank_math/frontend/title', 'metasync_suppress_yoast_title_for_loggedin', 99999);
2987 add_filter('rank_math/frontend/description', 'metasync_suppress_yoast_desc_for_loggedin', 99999);
2988
2989 function metasync_suppress_yoast_title_for_loggedin($title) {
2990 if (!metasync_is_otto_suppressed_for_current_user()) {
2991 return $title;
2992 }
2993 if (is_singular()) {
2994 $original = get_the_title();
2995 $sep = '-';
2996 if (function_exists('YoastSEO') || class_exists('WPSEO_Utils')) {
2997 $sep = apply_filters('document_title_separator', '-');
2998 }
2999 return $original . ' ' . $sep . ' ' . get_bloginfo('name');
3000 }
3001 return $title;
3002 }
3003
3004 function metasync_suppress_yoast_desc_for_loggedin($desc) {
3005 if (!metasync_is_otto_suppressed_for_current_user()) {
3006 return $desc;
3007 }
3008 # Return empty so Yoast/Rank Math falls back to auto-generated excerpt
3009 return '';
3010 }
3011
3012 function metasync_suppress_yoast_schema_for_loggedin($data) {
3013 if (!metasync_is_otto_suppressed_for_current_user()) {
3014 return $data;
3015 }
3016 if (is_singular() && isset($data['name'])) {
3017 $data['name'] = get_the_title() . ' - ' . get_bloginfo('name');
3018 }
3019 return $data;
3020 }
3021
3022 /**
3023 * Astra Theme Compatibility
3024 * Override Astra theme's title output with OTTO optimized title
3025 */
3026 function metasync_astra_title_override($title) {
3027 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
3028 return $title;
3029 }
3030
3031 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
3032 # it does not leak when OTTO is disabled for this page.
3033 if (metasync_is_otto_disabled_for_current_post()) {
3034 return $title;
3035 }
3036
3037 if (class_exists('Metasync_SEO_Conflict_Handler')) {
3038 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3039 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
3040 return $title;
3041 }
3042 }
3043
3044 # Check for WooCommerce shop page
3045 if (function_exists('is_shop') && is_shop()) {
3046 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3047 if ($shop_page_id) {
3048 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
3049 if (!empty($otto_title)) {
3050 return $otto_title;
3051 }
3052 }
3053 }
3054
3055 # Check for individual product pages
3056 if (function_exists('is_product') && is_product()) {
3057 $post_id = get_the_ID();
3058 if ($post_id) {
3059 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3060 if (!empty($otto_title)) {
3061 return $otto_title;
3062 }
3063 }
3064 }
3065
3066 # Check for product category pages
3067 if (is_tax('product_cat')) {
3068 $term = get_queried_object();
3069 if ($term && isset($term->term_id)) {
3070 # Check if OTTO has a title for this category
3071 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3072 if (!empty($otto_title)) {
3073 return $otto_title;
3074 }
3075 }
3076 }
3077
3078 # Check for product tag pages
3079 if (is_tax('product_tag')) {
3080 $term = get_queried_object();
3081 if ($term && isset($term->term_id)) {
3082 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3083 if (!empty($otto_title)) {
3084 return $otto_title;
3085 }
3086 }
3087 }
3088
3089 return $title;
3090 }
3091
3092 # Hook into Astra's title filters (multiple hooks for maximum compatibility)
3093 add_filter('astra_the_title', 'metasync_astra_title_override', 99);
3094 add_filter('astra_wp_title', 'metasync_astra_title_override', 99);
3095
3096 /**
3097 * WooCommerce Archive Title Override
3098 * This ensures OTTO title appears on WooCommerce shop, category, and tag pages
3099 */
3100 function metasync_woocommerce_archive_title($title) {
3101 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
3102 return $title;
3103 }
3104
3105 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
3106 # it does not leak when OTTO is disabled for this page.
3107 if (metasync_is_otto_disabled_for_current_post()) {
3108 return $title;
3109 }
3110
3111 if (class_exists('Metasync_SEO_Conflict_Handler')) {
3112 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3113 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
3114 return $title;
3115 }
3116 }
3117
3118 # Shop page
3119 if (function_exists('is_shop') && is_shop()) {
3120 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3121 if ($shop_page_id) {
3122 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
3123 if (!empty($otto_title)) {
3124 return $otto_title;
3125 }
3126 }
3127 }
3128
3129 # Product categories
3130 if (is_tax('product_cat')) {
3131 $term = get_queried_object();
3132 if ($term && isset($term->term_id)) {
3133 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3134 if (!empty($otto_title)) {
3135 return $otto_title;
3136 }
3137 }
3138 }
3139
3140 # Product tags
3141 if (is_tax('product_tag')) {
3142 $term = get_queried_object();
3143 if ($term && isset($term->term_id)) {
3144 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3145 if (!empty($otto_title)) {
3146 return $otto_title;
3147 }
3148 }
3149 }
3150
3151 return $title;
3152 }
3153 add_filter('woocommerce_page_title', 'metasync_woocommerce_archive_title', 99);
3154
3155 /**
3156 * wp_title Filter (Classic WordPress themes)
3157 * Fallback for themes that use wp_title()
3158 */
3159 function metasync_wp_title_override($title, $sep = '') {
3160 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
3161 return $title;
3162 }
3163
3164 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
3165 # it does not leak when OTTO is disabled for this page.
3166 if (metasync_is_otto_disabled_for_current_post()) {
3167 return $title;
3168 }
3169
3170 if (class_exists('Metasync_SEO_Conflict_Handler')) {
3171 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3172 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
3173 return $title;
3174 }
3175 }
3176
3177 # Shop page
3178 if (function_exists('is_shop') && is_shop()) {
3179 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3180 if ($shop_page_id) {
3181 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
3182 if (!empty($otto_title)) {
3183 return $otto_title;
3184 }
3185 }
3186 }
3187
3188 # Individual product pages
3189 if (function_exists('is_product') && is_product()) {
3190 $post_id = get_the_ID();
3191 if ($post_id) {
3192 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3193 if (!empty($otto_title)) {
3194 return $otto_title;
3195 }
3196 }
3197 }
3198
3199 # Product categories
3200 if (is_tax('product_cat')) {
3201 $term = get_queried_object();
3202 if ($term && isset($term->term_id)) {
3203 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3204 if (!empty($otto_title)) {
3205 return $otto_title;
3206 }
3207 }
3208 }
3209
3210 # Product tags
3211 if (is_tax('product_tag')) {
3212 $term = get_queried_object();
3213 if ($term && isset($term->term_id)) {
3214 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3215 if (!empty($otto_title)) {
3216 return $otto_title;
3217 }
3218 }
3219 }
3220
3221 # Regular singular posts / pages (non-WooCommerce)
3222 if (is_singular()) {
3223 $post_id = get_the_ID();
3224 if ($post_id) {
3225 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3226 if (!empty($otto_title)) {
3227 return $otto_title;
3228 }
3229 }
3230 }
3231
3232 return $title;
3233 }
3234 add_filter('wp_title', 'metasync_wp_title_override', 99, 2);
3235
3236 /**
3237 * Document Title Parts (WordPress 4.4+)
3238 * This is the modern way WordPress handles <title> tags
3239 */
3240 function metasync_document_title_parts($title_parts) {
3241 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
3242 return $title_parts;
3243 }
3244
3245 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
3246 # it does not leak when OTTO is disabled for this page.
3247 if (metasync_is_otto_disabled_for_current_post()) {
3248 return $title_parts;
3249 }
3250
3251 if (class_exists('Metasync_SEO_Conflict_Handler')) {
3252 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3253 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
3254 return $title_parts;
3255 }
3256 }
3257
3258 # Homepage (static front page backed by a real page)
3259 if (is_front_page() && !is_home()) {
3260 $front_id = (int) get_option('page_on_front');
3261 if ($front_id) {
3262 $otto_title = get_post_meta($front_id, '_metasync_otto_title', true);
3263 if (!empty($otto_title)) {
3264 $title_parts['title'] = $otto_title;
3265 unset($title_parts['tagline']);
3266 unset($title_parts['site']);
3267 }
3268 }
3269 }
3270
3271 # Shop page
3272 if (function_exists('is_shop') && is_shop()) {
3273 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3274 if ($shop_page_id) {
3275 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
3276 if (!empty($otto_title)) {
3277 $title_parts['title'] = $otto_title;
3278 unset($title_parts['tagline']);
3279 unset($title_parts['site']);
3280 }
3281 }
3282 }
3283
3284 # Individual product pages
3285 if (function_exists('is_product') && is_product()) {
3286 $post_id = get_the_ID();
3287 if ($post_id) {
3288 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3289 if (!empty($otto_title)) {
3290 $title_parts['title'] = $otto_title;
3291 unset($title_parts['tagline']);
3292 unset($title_parts['site']);
3293 }
3294 }
3295 }
3296
3297 # Product categories
3298 if (is_tax('product_cat')) {
3299 $term = get_queried_object();
3300 if ($term && isset($term->term_id)) {
3301 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3302 if (!empty($otto_title)) {
3303 # Replace the title part with OTTO title
3304 $title_parts['title'] = $otto_title;
3305 # Remove tagline and site name if present for cleaner title
3306 unset($title_parts['tagline']);
3307 unset($title_parts['site']);
3308 }
3309 }
3310 }
3311
3312 # Product tags
3313 if (is_tax('product_tag')) {
3314 $term = get_queried_object();
3315 if ($term && isset($term->term_id)) {
3316 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3317 if (!empty($otto_title)) {
3318 $title_parts['title'] = $otto_title;
3319 unset($title_parts['tagline']);
3320 unset($title_parts['site']);
3321 }
3322 }
3323 }
3324
3325 # Regular singular posts / pages (non-WooCommerce)
3326 if (is_singular()) {
3327 $post_id = get_the_ID();
3328 if ($post_id) {
3329 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3330 if (!empty($otto_title)) {
3331 $title_parts['title'] = $otto_title;
3332 unset($title_parts['tagline']);
3333 unset($title_parts['site']);
3334 }
3335 }
3336 }
3337
3338 return $title_parts;
3339 }
3340 add_filter('document_title_parts', 'metasync_document_title_parts', 99);
3341
3342 /**
3343 * Pre-get Document Title (WordPress 4.4+)
3344 * Final override before title is rendered
3345 */
3346 function metasync_pre_get_document_title($title) {
3347 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
3348 # Return empty to neutralize any SEO plugin (Yoast/RankMath) that may have
3349 # already set $title to the synced OTTO value at a lower priority.
3350 # An empty string tells WordPress to rebuild the title from default parts.
3351 return '';
3352 }
3353
3354 # Per-page "Disable OTTO" toggle: suppress persisted OTTO title output so
3355 # it does not leak when OTTO is disabled for this page. Return the incoming
3356 # title unchanged (do not force '') so a lower-priority SEO plugin value is
3357 # preserved — only the persisted OTTO value must be suppressed.
3358 if (metasync_is_otto_disabled_for_current_post()) {
3359 return $title;
3360 }
3361
3362 if (class_exists('Metasync_SEO_Conflict_Handler')) {
3363 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3364 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
3365 return $title;
3366 }
3367 }
3368
3369 # Homepage (static front page backed by a real page)
3370 if (is_front_page() && !is_home()) {
3371 $front_id = (int) get_option('page_on_front');
3372 if ($front_id) {
3373 $otto_title = get_post_meta($front_id, '_metasync_otto_title', true);
3374 if (!empty($otto_title)) {
3375 return $otto_title;
3376 }
3377 }
3378 }
3379
3380 # Shop page
3381 if (function_exists('is_shop') && is_shop()) {
3382 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3383 if ($shop_page_id) {
3384 $otto_title = get_post_meta($shop_page_id, '_metasync_otto_title', true);
3385 if (!empty($otto_title)) {
3386 return $otto_title;
3387 }
3388 }
3389 }
3390
3391 # Individual product pages
3392 if (function_exists('is_product') && is_product()) {
3393 $post_id = get_the_ID();
3394 if ($post_id) {
3395 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3396 if (!empty($otto_title)) {
3397 return $otto_title;
3398 }
3399 }
3400 }
3401
3402 # Product categories
3403 if (is_tax('product_cat')) {
3404 $term = get_queried_object();
3405 if ($term && isset($term->term_id)) {
3406 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3407 if (!empty($otto_title)) {
3408 return $otto_title;
3409 }
3410 }
3411 }
3412
3413 # Product tags
3414 if (is_tax('product_tag')) {
3415 $term = get_queried_object();
3416 if ($term && isset($term->term_id)) {
3417 $otto_title = get_term_meta($term->term_id, '_metasync_otto_title', true);
3418 if (!empty($otto_title)) {
3419 return $otto_title;
3420 }
3421 }
3422 }
3423
3424 # Regular singular posts / pages (non-WooCommerce)
3425 if (is_singular()) {
3426 $post_id = get_the_ID();
3427 if ($post_id) {
3428 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
3429 if (!empty($otto_title)) {
3430 return $otto_title;
3431 }
3432 }
3433 }
3434
3435 return $title;
3436 }
3437 add_filter('pre_get_document_title', 'metasync_pre_get_document_title', 99);
3438
3439 /**
3440 * Output OTTO meta description tag in <head> for WooCommerce pages
3441 * This is the critical function that actually renders meta descriptions to HTML
3442 * Unlike titles, WordPress doesn't automatically output meta descriptions from meta fields
3443 */
3444 function metasync_output_otto_meta_description() {
3445 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_disabled_for_loggedin() && is_user_logged_in()) {
3446 return;
3447 }
3448
3449 # Per-page "Disable OTTO" toggle: suppress persisted OTTO meta description
3450 # output so it does not leak when OTTO is disabled for this page.
3451 if (metasync_is_otto_disabled_for_current_post()) {
3452 return;
3453 }
3454
3455 // Mirror the conflict guard used in metasync_pre_get_document_title()
3456 // (around line 2817). When a third-party SEO plugin is active AND OTTO has no
3457 // live suggestions for this URL, defer to the SEO plugin's description instead
3458 // of rendering a stale _metasync_otto_description from a previous deployment.
3459 if (class_exists('Metasync_SEO_Conflict_Handler')) {
3460 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3461 if ($handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions()) {
3462 return;
3463 }
3464 }
3465
3466 // When synced to an active SEO plugin, that plugin outputs the
3467 // description from its native storage — skip OTTO's own description tag.
3468 // Only defer if the plugin's field is actually populated; a stale
3469 // or partial sync (timestamp present, plugin field empty) would otherwise
3470 // drop the description entirely, so fall through and emit OTTO's tag.
3471 if (is_singular()) {
3472 $post_id = get_the_ID();
3473 if ($post_id) {
3474 $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
3475 if (!empty($sync_ts) && class_exists('Metasync_SEO_Conflict_Handler')) {
3476 $handler = Metasync_SEO_Conflict_Handler::get_instance();
3477 if ($handler->has_active_seo_plugin() && $handler->active_plugin_has_description($post_id)) {
3478 return;
3479 }
3480 }
3481 }
3482 }
3483
3484 // Ownership rule, mirrored from
3485 // Metasync_SEO_Sidebar::output_seo_meta_description(): this emitter prints
3486 // the description only when OTTO's stored suggestion is the resolved
3487 // winner; the sidebar prints every other tier. The previous rule — stay
3488 // quiet whenever _metasync_seo_desc exists — matched the sidebar before
3489 // it resolved through Metasync_Seo_Precedence. Under the global priority
3490 // setting a stored custom value no longer means the sidebar will print
3491 // it, so the old check could silence BOTH printers (custom and OTTO set,
3492 // OTTO prioritised: no description tag at all) or, with the custom field
3493 // empty, leave both printing and ship two identical tags on delivery
3494 // paths the OTTO SSR buffer dedup never runs (cold/served-from-cache,
3495 // rate-limited, or Cloudflare-pixel mode).
3496 if (is_singular()) {
3497 $post_id = get_the_ID();
3498 if ($post_id) {
3499 if (!class_exists('Metasync_Seo_Precedence')) {
3500 $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
3501 if (is_readable($file)) {
3502 require_once $file;
3503 }
3504 }
3505 if (class_exists('Metasync_Seo_Precedence')) {
3506 $resolved = Metasync_Seo_Precedence::resolve(
3507 $post_id,
3508 Metasync_Seo_Precedence::FIELD_DESCRIPTION
3509 );
3510 $otto_owns = $resolved['key'] === Metasync_Seo_Precedence::KEY_OTTO_DESC;
3511 } else {
3512 # Partial-update fail-safe: no resolver, keep the
3513 # pre-resolver rule (quiet when the customer typed a value).
3514 $otto_owns = empty(get_post_meta($post_id, '_metasync_seo_desc', true));
3515 }
3516 if (!$otto_owns) {
3517 return;
3518 }
3519 }
3520 }
3521
3522 $description = null;
3523
3524 # Shop page
3525 if (function_exists('is_shop') && is_shop()) {
3526 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
3527 if ($shop_page_id) {
3528 $description = get_post_meta($shop_page_id, '_metasync_otto_description', true);
3529
3530 # Fallback to other description fields if OTTO description not found
3531 if (empty($description)) {
3532 $description = get_post_meta($shop_page_id, 'rank_math_description', true);
3533 }
3534 if (empty($description)) {
3535 $description = get_post_meta($shop_page_id, '_yoast_wpseo_metadesc', true);
3536 }
3537 if (empty($description)) {
3538 $description = get_post_meta($shop_page_id, 'meta_description', true);
3539 }
3540 }
3541 }
3542 # Individual product pages
3543 elseif (function_exists('is_product') && is_product()) {
3544 $post_id = get_the_ID();
3545 if ($post_id) {
3546 $description = get_post_meta($post_id, '_metasync_otto_description', true);
3547
3548 # Fallback to other description fields
3549 if (empty($description)) {
3550 $description = get_post_meta($post_id, 'rank_math_description', true);
3551 }
3552 if (empty($description)) {
3553 $description = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
3554 }
3555 if (empty($description)) {
3556 $description = get_post_meta($post_id, 'meta_description', true);
3557 }
3558 }
3559 }
3560 # Product categories
3561 elseif (is_tax('product_cat')) {
3562 $term = get_queried_object();
3563 if ($term && isset($term->term_id)) {
3564 $description = get_term_meta($term->term_id, '_metasync_otto_description', true);
3565
3566 # Fallback to other description fields
3567 if (empty($description)) {
3568 $description = get_term_meta($term->term_id, 'rank_math_description', true);
3569 }
3570 if (empty($description)) {
3571 $description = get_term_meta($term->term_id, '_yoast_wpseo_metadesc', true);
3572 }
3573 if (empty($description)) {
3574 $description = get_term_meta($term->term_id, 'woocommerce_meta_description', true);
3575 }
3576 if (empty($description)) {
3577 $description = get_term_meta($term->term_id, 'meta_description', true);
3578 }
3579 }
3580 }
3581 # Product tags
3582 elseif (is_tax('product_tag')) {
3583 $term = get_queried_object();
3584 if ($term && isset($term->term_id)) {
3585 $description = get_term_meta($term->term_id, '_metasync_otto_description', true);
3586
3587 # Fallback to other description fields
3588 if (empty($description)) {
3589 $description = get_term_meta($term->term_id, 'rank_math_description', true);
3590 }
3591 if (empty($description)) {
3592 $description = get_term_meta($term->term_id, '_yoast_wpseo_metadesc', true);
3593 }
3594 if (empty($description)) {
3595 $description = get_term_meta($term->term_id, 'meta_description', true);
3596 }
3597 }
3598 }
3599 # Regular singular posts / pages (non-WooCommerce)
3600 elseif (is_singular()) {
3601 $post_id = get_the_ID();
3602 if ($post_id) {
3603 $description = get_post_meta($post_id, '_metasync_otto_description', true);
3604 }
3605 }
3606
3607 # Output meta description tag if we found one
3608 if (!empty($description)) {
3609 # Escape for HTML attribute
3610 $description_escaped = esc_attr($description);
3611
3612 # Output the meta tag with OTTO marker
3613 echo '<meta name="description" content="' . $description_escaped . '" data-metasync-otto="true" />' . "\n";
3614 }
3615 }
3616 add_action('wp_head', 'metasync_output_otto_meta_description', 1);
3617