PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.6
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.6
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / public / class-metasync-rest-api.php

class-metasync-rest-api.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.6, at public/class-metasync-rest-api.php

3,789 lines 125.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * REST API functionality of the plugin.
9 *
10 * @link https://searchatlas.com
11 * @since 1.0.0
12 *
13 * @package Metasync
14 * @subpackage Metasync/public
15 */
16
17 /**
18 * REST API functionality extracted from Metasync_Public.
19 *
20 * Handles all REST API route registration and endpoint callbacks.
21 *
22 * @package Metasync
23 * @subpackage Metasync/public
24 * @author Engineering Team <[email protected]>
25 */
26
27 class Metasync_Rest_Api
28 {
29
30 /**
31 * The ID of this plugin.
32 *
33 * @since 1.0.0
34 * @access private
35 * @var string $plugin_name The ID of this plugin.
36 */
37 private $plugin_name;
38
39 /**
40 * The version of this plugin.
41 *
42 * @since 1.0.0
43 * @access private
44 * @var string $version The current version of this plugin.
45 */
46 private $version;
47
48 private const namespace = "metasync/v1";
49
50 private $escapers;
51 private $replacements;
52 private $common;
53 private $allowed_attributes;
54 private $schema;
55 private $metasync_option_data;
56
57 public function __construct($plugin_name, $version)
58 {
59 $this->plugin_name = $plugin_name;
60 $this->version = $version;
61 $this->allowed_attributes = array(
62 'ID',
63 'meta_description',
64 'meta_robots',
65 'meta_canonical',
66 'permalink',
67 'post_id',
68 'post_title',
69 'post_type',
70 'post_content',
71 'post_author',
72 'post_date',
73 'post_modified',
74 'post_name',
75 'post_parent',
76 'post_status',
77 );
78 $this->escapers = array("\\", "/", "\"");
79 $this->replacements = array("", "", "");
80 $this->common = new Metasync_Common();
81 // get all options
82 $this->metasync_option_data = Metasync::get_option('general');
83 $this->init_ajax_hooks();
84 }
85
86 public function init_ajax_hooks()
87 {
88 add_action('wp_ajax_metasync_otto_ajax_action', array($this,'metasyn_otto_ajax'));
89 }
90
91 /**
92 * Encode non-ASCII characters in a UTF-8 string as numeric HTML entities.
93 *
94 * Uses mb_encode_numericentity when the mbstring extension is loaded;
95 * otherwise walks the UTF-8 byte sequence manually so DOMDocument loading
96 * still works on hosts without mbstring.
97 *
98 * @param string $str UTF-8 input.
99 * @return string Same string with codepoints >= 0x80 replaced by &#NNN; entities.
100 */
101 private function encode_numeric_entities($str)
102 {
103 if (function_exists('mb_encode_numericentity')) {
104 return mb_encode_numericentity($str, [0x80, 0xFFFF, 0, 0xFFFF], 'UTF-8');
105 }
106
107 if ($str === '' || $str === null) {
108 return '';
109 }
110
111 $out = '';
112 $len = strlen($str);
113 $i = 0;
114 while ($i < $len) {
115 $byte = ord($str[$i]);
116
117 if ($byte < 0x80) {
118 $out .= $str[$i];
119 $i++;
120 continue;
121 }
122
123 if (($byte & 0xE0) === 0xC0 && $i + 1 < $len) {
124 $b2 = ord($str[$i + 1]);
125 if (($b2 & 0xC0) === 0x80) {
126 $cp = (($byte & 0x1F) << 6) | ($b2 & 0x3F);
127 $out .= '&#' . $cp . ';';
128 $i += 2;
129 continue;
130 }
131 } elseif (($byte & 0xF0) === 0xE0 && $i + 2 < $len) {
132 $b2 = ord($str[$i + 1]);
133 $b3 = ord($str[$i + 2]);
134 if (($b2 & 0xC0) === 0x80 && ($b3 & 0xC0) === 0x80) {
135 $cp = (($byte & 0x0F) << 12) | (($b2 & 0x3F) << 6) | ($b3 & 0x3F);
136 $out .= '&#' . $cp . ';';
137 $i += 3;
138 continue;
139 }
140 } elseif (($byte & 0xF8) === 0xF0 && $i + 3 < $len) {
141 $b2 = ord($str[$i + 1]);
142 $b3 = ord($str[$i + 2]);
143 $b4 = ord($str[$i + 3]);
144 if (($b2 & 0xC0) === 0x80 && ($b3 & 0xC0) === 0x80 && ($b4 & 0xC0) === 0x80) {
145 $cp = (($byte & 0x07) << 18) | (($b2 & 0x3F) << 12) | (($b3 & 0x3F) << 6) | ($b4 & 0x3F);
146 $out .= '&#' . $cp . ';';
147 $i += 4;
148 continue;
149 }
150 }
151
152 $out .= $str[$i];
153 $i++;
154 }
155
156 return $out;
157 }
158
159 private function filter_post_attributes($posts)
160 {
161 $pi = -1;
162 foreach ($posts as $post) {
163 $pi++;
164 if ($post == null)
165 return false; // post not found
166
167 foreach ($post as $key => $value) {
168 if (!in_array($key, $this->allowed_attributes)) {
169 unset($posts[$pi]->{$key});
170 }
171 }
172 $posts[$pi]->post_id = $posts[$pi]->ID;
173 $posts[$pi]->permalink = get_permalink($posts[$pi]->ID);
174 }
175 return $posts;
176 }
177
178 public function metasyn_otto_ajax() {
179 // Check nonce for security
180 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'otto_nonce')) {
181 wp_send_json_error('Invalid nonce');
182 wp_die();
183 }
184 $post_id = sanitize_text_field($_POST['post_id']);
185 $current_url = get_permalink($post_id);
186
187 $header_html = get_post_meta($post_id, '_otto_header_html_json', true);
188
189 // Example API call using wp_remote_get()
190
191
192 if (is_wp_error($header_html)) {
193 wp_send_json_error('API call failed');
194 } else {
195 wp_send_json_success(json_decode($header_html, true));
196 }
197
198 wp_die(); // Always terminate after an AJAX call
199 }
200
201 public function otto_header_data() {
202 global $post;
203
204 // Get the current post ID
205 $post_id = $post->ID;
206
207 // Get the current time and the last update time from post meta
208 $current_time = current_time('timestamp');
209 $last_update_time = get_post_meta($post_id, '_otto_last_update_time', true);
210
211 // Set the interval for 24 hours (in seconds)
212 $interval = 24 * 60 * 60;
213
214 // Check if the last update time is set or if 24 hours have passed
215 if (!$last_update_time || ($current_time - $last_update_time) >= $interval) {
216 // Get the current URL
217 $current_url = get_permalink($post_id);
218
219 // Use endpoint manager to get the correct API URL
220 $api_endpoint = class_exists('Metasync_Endpoint_Manager')
221 ? Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS')
222 : 'https://sa.searchatlas.com/api/v2/otto-url-details';
223
224 // Call the API
225 $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
226
227 // Check if the API call was successful
228 if (!is_wp_error($response)) {
229 $body = wp_remote_retrieve_body($response);
230 $data = json_decode($body, true); // Decode JSON into an associative array
231
232 // Update the post meta with the new data and timestamp
233 update_post_meta($post_id, '_otto_header_html', $data['header_html_insertion']);
234 update_post_meta($post_id, '_otto_last_update_time', $current_time);
235 }
236 }
237
238 // Get the saved HTML from post meta
239 $header_html = get_post_meta($post_id, '_otto_header_html', true);
240
241 // Display the HTML with security measures
242 if ($header_html) {
243 echo "<!-- Otto Start -->";
244 // SECURITY FIX: Sanitize HTML to prevent XSS while allowing safe HTML
245 echo wp_kses($header_html, array(
246 'style' => array(),
247 'link' => array('rel' => array(), 'href' => array(), 'type' => array()),
248 'meta' => array('name' => array(), 'content' => array(), 'property' => array()),
249 'script' => array('type' => array(), 'src' => array()),
250 // Add other safe tags as needed
251 ));
252 echo "<!-- Otto End -->";
253 }
254 }
255
256 public function rest_authorization_middleware()
257 {
258 # $get_data = metasync_sanitize_input_array($_GET);
259
260 # mailchamp causing fatal error if sanitize_post used
261 $get_data = array_map('sanitize_text_field', $_GET);
262 if (!isset($get_data['apikey']))
263 return false;
264 # $apiKey = sanitize_text_field($get_data['apikey']) ?? null;
265 $apiKey = $get_data['apikey'] ?? null;
266
267 $getOptions = Metasync::get_option('general');
268 $getApiKeyFromSettings = $getOptions['apikey'] ?? null;
269
270 if ($apiKey === $getApiKeyFromSettings)
271 return true;
272 return false;
273 }
274
275
276 public function metasync_register_rest_routes()
277 {
278 // Critical Routes
279 /*
280 createItem
281 createPage
282 updateItems
283 updatePage
284 deleteItem
285 getPagesList
286 getPostByURL
287 */
288 register_rest_route(
289 $this::namespace ,
290 'getItems',
291 array(
292 array(
293 'methods' => 'GET',
294 'callback' => array($this, 'get_items'),
295 'permission_callback' => array($this, 'rest_authorization_middleware')
296 ),
297 'schema' => array($this, 'get_item_schema'),
298 )
299 );
300
301
302
303 register_rest_route($this::namespace , 'postCategories',array(
304 array(
305 'methods' => 'GET',
306 'callback' => array($this, 'post_categories'),
307 'permission_callback' => array($this, 'rest_authorization_middleware')
308 ),
309 'schema' => array($this, 'get_item_schema'),
310 )
311 );
312
313
314 register_rest_route(
315 $this::namespace ,
316 'updateItems',
317 array(
318 array(
319 'methods' => 'POST',
320 'callback' => array($this, 'update_items'),
321 'permission_callback' => array($this, 'rest_authorization_middleware')
322 ),
323 'schema' => array($this, 'get_item_schema'),
324 )
325 );
326
327 # add otto pixel rest route
328 register_rest_route(
329 $this::namespace ,
330 'otto_crawl_notify',
331 array(
332 array(
333 'methods' => 'POST',
334 'callback' => 'metasync_otto_crawl_notify',
335 'permission_callback' => array($this, 'rest_authorization_middleware')
336 ),
337 'schema' => array($this, 'get_item_schema'),
338 )
339 );
340
341 register_rest_route(
342 $this::namespace ,
343 'createItem',
344 array(
345 array(
346 'methods' => 'POST',
347 'callback' => array($this, 'create_item'),
348 'permission_callback' => array($this, 'rest_authorization_middleware')
349 ),
350 'schema' => array($this, 'get_item_schema'),
351 )
352 );
353
354 register_rest_route(
355 $this::namespace ,
356 'setLandingPage',
357 array(
358 array(
359 'methods' => 'POST',
360 'callback' => array($this, 'set_landing_page'),
361 'permission_callback' => array($this, 'rest_authorization_middleware')
362 ),
363 'schema' => array($this, 'get_item_schema'),
364 )
365 );
366
367 register_rest_route(
368 $this::namespace ,
369 'deleteItem',
370 array(
371 array(
372 'methods' => 'DELETE',
373 'callback' => array($this, 'delete_item'),
374 'permission_callback' => array($this, 'rest_authorization_middleware')
375 ),
376 'schema' => array($this, 'get_item_schema'),
377 )
378 );
379
380 register_rest_route(
381 $this::namespace ,
382 'getPagesList',
383 array(
384 array(
385 'methods' => 'GET',
386 'callback' => function () {
387 $pagesList = array();
388 $pages = get_posts([
389 'post_type' => 'page',
390 'post_status' => array('publish'),
391 'nopaging' => true
392 ]);
393 foreach ($pages as $page) {
394 array_push($pagesList, array(
395 'post_id' => $page->ID,
396 'post_title' => $page->post_title,
397 'post_url' => get_permalink($page->ID), //$page->guid
398 )
399 );
400 }
401 return rest_ensure_response($pagesList);
402 },
403 'permission_callback' => array($this, 'rest_authorization_middleware')
404 ),
405 'schema' => array($this, 'get_item_schema'),
406 )
407 );
408
409 register_rest_route(
410 $this::namespace ,
411 'getPostByURL',
412 array(
413 array(
414 'methods' => 'GET',
415 'callback' => function () {
416 $getPostID = url_to_postid(sanitize_url($_GET['url']));
417
418 if ($getPostID==0) {
419 $response = false;
420 }else{
421 $response = $this->filter_post_attributes([
422 get_post($getPostID)
423 ]);
424 }
425
426 if ($response == false) {
427 $response = ['post_id' => -1];
428 } else {
429 $response = $response[0];
430 }
431 return rest_ensure_response($response);
432 },
433 'permission_callback' => array($this, 'rest_authorization_middleware')
434 ),
435 'schema' => array($this, 'get_item_schema'),
436 )
437 );
438
439 register_rest_route(
440 $this::namespace ,
441 'createPage',
442 array(
443 array(
444 'methods' => 'POST',
445 'callback' => array($this, 'create_page'),
446 'permission_callback' => array($this, 'rest_authorization_middleware')
447 ),
448 'schema' => array($this, 'get_item_schema'),
449 )
450 );
451
452 register_rest_route(
453 $this::namespace ,
454 'updatePage',
455 array(
456 array(
457 'methods' => 'POST',
458 'callback' => array($this, 'update_page'),
459 'permission_callback' => array($this, 'rest_authorization_middleware')
460 ),
461 'schema' => array($this, 'get_item_schema'),
462 )
463 );
464
465 register_rest_route(
466 $this::namespace ,
467 'deletePage',
468 array(
469 array(
470 'methods' => 'DELETE',
471 'callback' => array($this, 'delete_page'),
472 'permission_callback' => array($this, 'rest_authorization_middleware')
473 ),
474 'schema' => array($this, 'get_item_schema'),
475 )
476 );
477
478 register_rest_route(
479 $this::namespace ,
480 'posts',
481 array(
482 array(
483 'methods' => 'GET',
484 'callback' => function () {
485 $query = new WP_Query(
486 array(
487 'nopaging' => true,
488 'post_type' => array('post', 'page')
489 )
490 );
491 return rest_ensure_response(
492 $this->filter_post_attributes(
493 $query->posts
494 )
495 );
496 },
497 'permission_callback' => array($this, 'rest_authorization_middleware')
498 ),
499 'schema' => array($this, 'get_item_schema'),
500 )
501 );
502
503
504
505 register_rest_route(
506 $this::namespace ,
507 'lglogin',
508 array(
509 array(
510 'methods' => 'POST',
511 'callback' => array($this, 'linkgraph_login'),
512 'permission_callback' => array($this, 'rest_authorization_middleware')
513 ),
514 'schema' => array($this, 'get_item_schema'),
515 )
516 );
517
518 register_rest_route(
519 $this::namespace ,
520 'syncHeartbeatData',
521 array(
522 array(
523 'methods' => 'POST',
524 'callback' => array($this, 'sync_heartbeat_data'),
525 'permission_callback' => array($this, 'rest_authorization_middleware')
526 ),
527 'schema' => array($this, 'get_item_schema'),
528 )
529 );
530
531 register_rest_route(
532 $this::namespace ,
533 'getHeartbeatErrorLogs',
534 array(
535 array(
536 'methods' => 'GET',
537 'callback' => array($this, 'get_heartbeat_errorlogs'),
538 'permission_callback' => array($this, 'rest_authorization_middleware')
539 ),
540 'schema' => array($this, 'get_item_schema'),
541 )
542 );
543
544 register_rest_route(
545 $this::namespace ,
546 'getErrorLogs',
547 array(
548 array(
549 'methods' => 'GET',
550 'callback' => array($this, 'get_errorlogs'),
551 'permission_callback' => array($this, 'rest_authorization_middleware')
552 ),
553 'schema' => array($this, 'get_item_schema'),
554 )
555 );
556
557 register_rest_route(
558 $this::namespace ,
559 'getPostByID',
560 array(
561 array(
562 'methods' => 'GET',
563 'callback' => function ($request) {
564 $getPostID = $request->get_param('ID');
565 if (is_null($getPostID) || !is_numeric($getPostID)) {
566 wp_send_json_error(array('message' => 'ID is missing or invalid'), 400);
567 }
568 $post = get_post($getPostID);
569 if ($post === null) {
570 wp_send_json_error(array('message' => 'Post not found'), 400);
571
572 }else{
573 wp_send_json_success(array('message' => 'ID is valid'),200);
574 }
575
576
577 },
578 'permission_callback' => array($this, 'rest_authorization_middleware')
579 ),
580 'schema' => array($this, 'get_item_schema'),
581 )
582 );
583
584 register_rest_route($this::namespace, 'pageList', array(
585 'methods' => 'POST',
586 'callback' => array($this, 'get_pages_list'),
587 'args' => array(
588 'post_type' => array(
589 'required' => true,
590 'validate_callback' => function ($param, $request, $key) {
591 return is_string($param) && ($param === 'page' || $param === 'post');
592 }
593 )
594 ),
595 'permission_callback' => array($this, 'rest_authorization_middleware'),
596 'schema' => array($this, 'get_item_schema'),
597 ));
598
599 # Add the new getPostData endpoint
600 register_rest_route(
601 $this::namespace,
602 'getPostData',
603 array(
604 array(
605 'methods' => 'GET',
606 'callback' => array($this, 'get_post_data'),
607 'permission_callback' => array($this, 'rest_authorization_middleware')
608 ),
609 'schema' => array($this, 'get_item_schema'),
610 )
611 );
612
613 # Search Atlas Connect callback endpoint - SA platform calls this with API key and Otto UUID
614 register_rest_route(
615 $this::namespace,
616 'searchatlas/connect/callback',
617 array(
618 array(
619 'methods' => 'POST',
620 'callback' => array($this, 'handle_searchatlas_api_callback'),
621 'permission_callback' => array($this, 'validate_searchatlas_callback_permission')
622 ),
623 'schema' => array($this, 'get_item_schema'),
624 )
625 );
626
627 # Key file creation endpoint
628 register_rest_route(
629 $this::namespace,
630 'createKeyFile',
631 array(
632 array(
633 'methods' => 'POST',
634 'callback' => array($this, 'create_key_file'),
635 'permission_callback' => array($this, 'rest_authorization_middleware')
636 ),
637 'schema' => array($this, 'get_item_schema'),
638 )
639 );
640
641 # OTTO SSR Status endpoint - Public endpoint to check if OTTO SSR is enabled
642 register_rest_route(
643 $this::namespace,
644 'otto_ssr_status',
645 array(
646 array(
647 'methods' => 'GET',
648 'callback' => array($this, 'get_otto_ssr_status'),
649 'permission_callback' => '__return_true' // Public access
650 ),
651 array(
652 'methods' => 'POST',
653 'callback' => array($this, 'set_otto_ssr_status'),
654 'permission_callback' => array($this, 'rest_authorization_middleware')
655 ),
656 'schema' => array($this, 'get_item_schema'),
657 )
658 );
659
660 # OTTO Configuration Status endpoint - Check if OTTO is active and properly configured
661 register_rest_route(
662 $this::namespace,
663 'otto_config_status',
664 array(
665 array(
666 'methods' => 'GET',
667 'callback' => array($this, 'get_otto_config_status'),
668 'permission_callback' => '__return_true' // Public access
669 ),
670 'schema' => array($this, 'get_item_schema'),
671 )
672 );
673
674 # Plugin Version endpoint - Returns the active plugin version
675 register_rest_route(
676 $this::namespace,
677 'version',
678 array(
679 array(
680 'methods' => 'GET',
681 'callback' => array($this, 'get_plugin_version'),
682 'permission_callback' => '__return_true' // Public access
683 ),
684 'schema' => array($this, 'get_item_schema'),
685 )
686 );
687
688 # Support Token Authentication endpoint
689
690 }
691
692
693 /**
694 * Get post data endpoint
695 * Accepts post_id or post_url and returns post details
696 * Only returns data for post type 'post'
697 */
698 public function get_post_data($request) {
699 $get_data = $request->get_params();
700 $post_id = null;
701 $post = null;
702
703 # Fetch post
704 if (!empty($get_data['post_id'])) {
705 $post_id = intval($get_data['post_id']);
706 $post = get_post($post_id);
707 } elseif (!empty($get_data['post_url'])) {
708 $post_id = url_to_postid(sanitize_url($get_data['post_url']));
709 if ($post_id > 0) {
710 $post = get_post($post_id);
711 }
712 }
713
714 # Check if post exists
715 if (!$post || $post_id <= 0) {
716 return rest_ensure_response(array('error' => 'no blog post found'), 404);
717 }
718
719 # Check if post type is 'post', return error if not
720 if ($post->post_type !== 'post') {
721 return rest_ensure_response(array('error' => 'only post type is supported'), 400);
722 }
723
724 # Render content
725 $post_content = $post->post_content;
726 if (strpos($post_content, '[et_pb_') !== false) {
727 # Load Divi modules if available
728 if (function_exists('et_builder_add_main_elements')) {
729 et_builder_add_main_elements();
730 }
731 # Render Divi content to HTML
732 if (function_exists('et_builder_render_layout')) {
733 $post_content = et_builder_render_layout($post_content);
734 } else {
735 $post_content = 'Divi render function not found';
736 }
737 } else {
738 # Standard WP content filter
739 $post_content = apply_filters('the_content', $post_content);
740 }
741
742 # Get featured image URL (full size, or null)
743 $featured_image = null;
744 if (has_post_thumbnail($post_id)) {
745 $featured_image = [
746 'url' => get_the_post_thumbnail_url($post_id, 'full'),
747 'id' => get_post_thumbnail_id($post_id),
748 'alt' => get_post_meta(get_post_thumbnail_id($post_id), '_wp_attachment_image_alt', true) ?: ''
749 ];
750 }
751
752 # Get post categories
753 $categories = get_the_category($post_id);
754 $category_names = array();
755 if (!empty($categories)) {
756 foreach ($categories as $category) {
757 $category_names[] = $category->name;
758 }
759 }
760
761 # Prepare response data
762 $response_data = array(
763 'post_content' => $post_content,
764 'post_title' => $post->post_title,
765 'post_status' => $post->post_status,
766 'otto_ai_page' => false,
767 'comment_status' => $post->comment_status,
768 'permalink' => $post->post_name,
769 'is_landing_page' => false,
770 'post_categories' => $category_names,
771 'post_id' => $post_id,
772 'post_type' => $post->post_type,
773 'post_parent' => $post->post_parent,
774 'featured_image' => $featured_image
775 );
776
777 return rest_ensure_response($response_data);
778 }
779
780 /**
781 * Get OTTO SSR status endpoint
782 * Public endpoint to check if OTTO Server Side Rendering is enabled
783 *
784 * @param WP_REST_Request $request Request object
785 * @return WP_REST_Response Response with active status
786 */
787 public function get_otto_ssr_status($request) {
788 # OTTO SSR is always enabled by default
789 # Prepare response - always return active as true
790 $response_data = array(
791 'active' => 'true'
792 );
793
794 return rest_ensure_response($response_data);
795 }
796
797 /**
798 * Set OTTO SSR status endpoint
799 * Authenticated endpoint to activate or deactivate OTTO Server Side Rendering
800 *
801 * @param WP_REST_Request $request Request object
802 * @return WP_REST_Response Response with updated active status
803 */
804 public function set_otto_ssr_status($request) {
805 # OTTO SSR is always enabled by default - this endpoint is kept for backwards compatibility
806 # Prepare response - SSR is always active
807 $response_data = array(
808 'active' => 'true',
809 'message' => 'OTTO SSR is always enabled by default.',
810 'success' => true
811 );
812
813 return rest_ensure_response($response_data);
814 }
815
816 /**
817 * Get OTTO Configuration Status endpoint
818 * Public endpoint to check if OTTO is active and API Key/UUID are correctly set
819 *
820 * @param WP_REST_Request $request Request object
821 * @return WP_REST_Response Response with configuration status
822 */
823 public function get_otto_config_status($request) {
824 # Get MetaSync options
825 $metasync_options = get_option('metasync_options');
826 $general_options = $metasync_options['general'] ?? array();
827
828 # OTTO SSR is always enabled by default
829 $is_otto_active = true;
830
831 # Check if API Key is set (not empty)
832 $api_key = $general_options['searchatlas_api_key'] ?? '';
833 $has_api_key = !empty($api_key);
834
835 # Check if UUID is set (not empty)
836 $otto_uuid = $general_options['otto_pixel_uuid'] ?? '';
837 $has_uuid = !empty($otto_uuid);
838
839 # Determine if fully configured (API key and UUID set - SSR always active)
840 $is_configured = $has_api_key && $has_uuid;
841
842 # Granular status for backend messaging (PR1: heartbeat reliability)
843 if ($has_api_key && $has_uuid) {
844 $status = 'configured';
845 } elseif ($has_api_key && !$has_uuid) {
846 $status = 'plugin_active_sso_partial';
847 } else {
848 $status = 'plugin_active_no_sso';
849 }
850
851 # Plugin version and timestamps (ISO 8601 UTC where set)
852 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
853 $last_heartbeat_at = $general_options['last_heartbeat_at'] ?? null;
854 $sso_completed_at = $general_options['sso_completed_at'] ?? null;
855
856 # Prepare response (backward compatible + new fields)
857 $response_data = array(
858 'status' => $status,
859 'configured' => $is_configured,
860 'otto_active' => $is_otto_active,
861 'has_api_key' => $has_api_key,
862 'has_uuid' => $has_uuid,
863 'plugin_version' => $plugin_version,
864 'last_heartbeat_at' => $last_heartbeat_at,
865 'sso_completed_at' => $sso_completed_at,
866 );
867
868 return rest_ensure_response($response_data);
869 }
870
871 /**
872 * Get Plugin Version endpoint
873 * Public endpoint that returns the active plugin version
874 *
875 * @param WP_REST_Request $request Request object
876 * @return WP_REST_Response Response with version information
877 * @since 2.5.15
878 */
879 public function get_plugin_version($request) {
880 # Get the plugin version from the constant
881 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
882
883 # Get plugin name and other metadata
884 $plugin_name = Metasync::get_effective_plugin_name();
885 $plugin_slug = 'metasync';
886
887 # Get plugin file path to retrieve additional metadata if needed
888 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
889 $plugin_data = array();
890
891 if (file_exists($plugin_file) && function_exists('get_plugin_data')) {
892 require_once ABSPATH . 'wp-admin/includes/plugin.php';
893 $plugin_data = get_plugin_data($plugin_file, false, false);
894 }
895
896 # Prepare response
897 $response_data = array(
898 'version' => $plugin_version,
899 'plugin_name' => $plugin_name,
900 'plugin_slug' => $plugin_slug,
901 'wordpress_version' => get_bloginfo('version'),
902 'php_version' => PHP_VERSION,
903 'plugin_uri' => !empty($plugin_data['PluginURI']) ? $plugin_data['PluginURI'] : '',
904 'author' => !empty($plugin_data['Author']) ? $plugin_data['Author'] : 'Search Atlas',
905 'author_uri' => !empty($plugin_data['AuthorURI']) ? $plugin_data['AuthorURI'] : 'https://searchatlas.com',
906 );
907
908 return rest_ensure_response($response_data);
909 }
910
911 public function post_categories() {
912 $categories = get_categories(array(
913 'hide_empty' => false,
914 ));
915
916 $categories = array_map(function($category) {
917 return [
918 'id' => $category->term_id,
919 'name' => $category->name,
920 'parent' => $category->parent,
921 ];
922 }, $categories);
923
924 $hierarchy = $this->build_category_hierarchy($categories);
925
926 return new WP_REST_Response($hierarchy, 200);
927 }
928
929 public function build_category_hierarchy($categories, $parentId = 0) {
930 $result = [];
931 foreach ($categories as $category) {
932 if ($category['parent'] == $parentId) {
933 $children = $this->build_category_hierarchy($categories, $category['id']);
934 if ($children) {
935 $category['children'] = $children;
936 }
937 $result[] = $category;
938 }
939 }
940 return $result;
941 }
942
943 public function get_errorlogs()
944 {
945 $get_data = metasync_sanitize_input_array($_GET);
946 if (!isset($get_data['limit']))
947 return false;
948 $limit = sanitize_text_field($get_data['limit']) ?? null;
949
950 require_once plugin_dir_path(__DIR__) . 'includes/class-metasync-errorlogs.php';
951
952 $errorLogClass = new ErrorLog();
953 $response = $errorLogClass->getParsedLogFile();
954
955 if (!empty($response)) {
956 // If no limit is specified, return all logs, otherwise, return the last $limit entries.
957 $logsToReturn = ($limit === -1) ? $response : array_slice($response, -$limit);
958
959 // Reverse the order of the logs
960 $logsToReturn = array_reverse($logsToReturn);
961
962 return rest_ensure_response($logsToReturn);
963 }
964 }
965
966 private function get_post_author_id($post)
967 {
968 $post_author = isset($post['post_author']) ? sanitize_text_field($post['post_author']) : 1;
969 wp_set_current_user($post_author);
970
971 $current_user = 1;
972 if (get_current_user_id()) {
973 return wp_get_current_user()->ID;
974 }
975
976 return $current_user;
977 }
978
979 private function get_random_user_id_by_roles(?array $roles = [])
980 {
981 $users = get_users(array('role__in' => $roles, 'fields' => 'ids'));
982 $post_author = 1;
983 if (!empty($users)) {
984 $key = array_rand($users);
985 $post_author = $users[$key];
986 }
987
988 return $post_author;
989 }
990
991
992 private function htmlToElementorBlock($node) {
993 $result = [];
994
995 if ($node->nodeType === XML_TEXT_NODE) {
996 // Text node
997 return $node->nodeValue;
998 } else{
999 // Element node
1000 $result['id'] = uniqid(); // Generate unique ID for the element
1001 $result['elType'] = 'widget'; // Assume all elements are widgets
1002 if (in_array(strtolower($node->nodeName), array('h1', 'h2', 'h3', 'h4', 'h5','h6'))) {
1003 // Handle heading elements
1004 $result['settings']['title'] = $node->nodeValue;
1005 $result['settings']['header_size'] = $node->nodeName;
1006
1007 # Check if the heading already has an ID
1008 $existing_id = $node->getAttribute('id');
1009
1010 # If the ID exists, assign it as _element_id so Elementor renders it in HTML
1011 if (!empty($existing_id)) {
1012 $result['settings']['_element_id'] = $existing_id;
1013 }
1014 if(isset($this->metasync_option_data['enabled_elementor_plugin_css']) && isset($this->metasync_option_data['enabled_elementor_plugin_css_color']) && $this->metasync_option_data['enabled_elementor_plugin_css']!=="default"){
1015 $result['settings']['title_color'] = $this->metasync_option_data['enabled_elementor_plugin_css_color']; // Set default title color
1016 }
1017 $result['settings']['typography_typography'] = 'custom';
1018 $result['settings']['typography_font_family'] = 'Roboto';
1019 $result['settings']['typography_font_weight'] = '600';
1020 $result['widgetType'] = 'heading';
1021 }elseif($node->nodeName==='iframe'){ // Correction in the name
1022 $result["settings"]= array('html'=> $node->ownerDocument->saveHTML($node));
1023 $result['widgetType'] = 'html';
1024 }elseif ($node->nodeName === 'img') {
1025 // Handle image elements source, title and alternative text
1026 $src_url = $node->getAttribute('src');
1027 $alt_text = $node->getAttribute('alt');
1028 $title_text = $node->getAttribute('title');
1029 // upload the image to wordpress and the id of the image and url
1030 $attachment_id = $this->common->upload_image_by_url($src_url,$alt_text,$title_text);
1031 $new_src_url = wp_get_attachment_url($attachment_id);
1032 // use the new image url to elementor
1033 $result['settings']['image']['url'] = $new_src_url;
1034
1035 $result['settings']['image']['id'] =$attachment_id; // Generate unique ID for the image
1036 $result['settings']['image']['size'] = '';
1037 $result['settings']['image']['alt'] = $alt_text; // Set default alt text
1038 $result['settings']['image']['source'] = 'library';
1039 // check if the title is empty or not
1040 if($title_text !== ""){
1041 $result['settings']['image']['title'] = $title_text;
1042 }
1043 $result['widgetType'] = 'image';
1044 } elseif ($node->nodeName === 'p') {
1045 // Handle paragraph elements
1046 $node->setAttribute('class', 'metasyncPara');
1047 $result["settings"]= array('editor'=> $node->ownerDocument->saveHTML($node));
1048 $result["elements"]= array();
1049 $result['widgetType'] = 'text-editor';
1050 } elseif($node->nodeName === 'table'|| $node->nodeName === 'ul' || $node->nodeName === 'ol') {
1051 if($node->nodeName === 'table'){
1052 $node->setAttribute('class', 'metasyncTable');
1053 }
1054 $result["settings"]= array('editor'=> $node->ownerDocument->saveHTML($node));
1055 $result["elements"]= array();
1056 $result['widgetType'] = 'text-editor';
1057 }elseif ($node->nodeName === 'blockquote') {
1058 # Add a class
1059 $node->setAttribute('class', 'metasyncBlockquote');
1060 # Set the HTML content inside the Elementor "text-editor" widget
1061 $html = $node->ownerDocument->saveHTML($node);
1062 # Insert blockquote into editor content
1063 $result["settings"] = array('editor' => $html);
1064 # No child widgets or inner elements inside this block
1065 $result["elements"] = array();
1066 # Specify that this content should use the "text-editor" widget type
1067 $result['widgetType'] = 'text-editor';
1068 }
1069
1070 if(isset($result['widgetType'])){
1071 return $result;
1072 }
1073
1074 }
1075 }
1076 private function elementorBlockData($content){
1077 $dom = new DOMDocument();
1078 @$dom->loadHTML($this->encode_numeric_entities($content));
1079
1080 $outputArray = [];
1081 foreach ( $dom->getElementsByTagName('*') as $rootElement) {
1082 if($rootElement->nodeName!=='html' && $rootElement->nodeName!=='body' &&
1083 $rootElement->nodeName!=='tbody'&& $rootElement->nodeName!=='tfoot' && $rootElement->nodeName!=='tr' && $rootElement->nodeName!=='th' && $rootElement->nodeName!=='td'){
1084 $htmlArray = $this->htmlToElementorBlock($rootElement);
1085 # $outputArray[] = $htmlArray;
1086 # Only add non-null values to the output array
1087 # non-null changed to not-empty
1088 if (!empty($htmlArray)) {
1089 $outputArray[] = $htmlArray;
1090 }
1091 }
1092 }
1093 return $outputArray;
1094 }
1095
1096 private function gutenbergBlockData($content) {
1097 // Validate and sanitize content before parsing
1098 if (empty($content) || !is_string($content)) {
1099 return [];
1100 }
1101
1102 // Trim and ensure content is valid
1103 $content = trim($content);
1104 if (empty($content)) {
1105 return [];
1106 }
1107
1108 // Clean content: remove any leading/trailing whitespace and ensure it starts with a valid HTML tag
1109 // If content starts with an entity or invalid character, wrap it
1110 $content = preg_replace('/^[\s\x{200B}-\x{200D}\x{FEFF}]+/u', '', $content);
1111 $content = preg_replace('/[\s\x{200B}-\x{200D}\x{FEFF}]+$/u', '', $content);
1112
1113 // Use modern Dom\HTMLDocument for PHP 8.4+ which natively supports HTML5
1114 // Otherwise fall back to DOMDocument with error suppression
1115 $dom = null;
1116 if (class_exists('Dom\HTMLDocument')) {
1117 // PHP 8.4+ with native HTML5 support
1118 // Wrap content in HTML structure if it's not already a complete document
1119 $wrapped_content = trim($content);
1120 $isCompleteDocument = (stripos($wrapped_content, '<!DOCTYPE') === 0) || (stripos($wrapped_content, '<html') === 0);
1121
1122 if (!$isCompleteDocument) {
1123 // Ensure content is properly formatted before wrapping
1124 $wrapped_content = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $wrapped_content . '</body></html>';
1125 }
1126
1127 try {
1128 $dom = @Dom\HTMLDocument::createFromString($wrapped_content);
1129 if ($dom === null) {
1130 throw new Exception('Dom\HTMLDocument::createFromString returned null');
1131 }
1132 } catch (Throwable $e) {
1133 // Fallback to DOMDocument if Dom\HTMLDocument fails
1134 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1135 error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage());
1136 }
1137 $dom = new DOMDocument();
1138 libxml_use_internal_errors(true);
1139 $encoded_content = $this->encode_numeric_entities($content);
1140 @$dom->loadHTML($encoded_content);
1141 libxml_clear_errors();
1142 libxml_use_internal_errors(false);
1143 }
1144 } else {
1145 // Fallback for older PHP versions
1146 $dom = new DOMDocument();
1147
1148 // Suppress libxml errors for HTML5 tags that aren't recognized in older libxml
1149 libxml_use_internal_errors(true);
1150
1151 // Ensure content is wrapped in a proper HTML structure
1152 $htmlContent = $content;
1153 if (!preg_match('/^\s*<(!DOCTYPE|html|body)/i', $content)) {
1154 $htmlContent = '<!DOCTYPE html><html><body>' . $content . '</body></html>';
1155 }
1156
1157 $dom->loadHTML($this->encode_numeric_entities($htmlContent));
1158
1159 // Clear any libxml errors and restore error handling
1160 libxml_clear_errors();
1161 libxml_use_internal_errors(false);
1162 }
1163
1164 $outputArray = [];
1165
1166 // Iterate through each element in the HTML
1167 foreach ($dom->getElementsByTagName('*') as $rootElement) {
1168 // If the element is not one of the specified HTML tags, convert it to a Gutenberg block
1169 if (!in_array($rootElement->nodeName, ['html', 'body', 'tr', 'th', 'td'])) {
1170 $htmlArray = $this->htmlToGutenbergBlock($rootElement);
1171 if(!is_null($htmlArray)){
1172 $outputArray[] = $htmlArray;
1173 }
1174 }
1175 }
1176
1177 return $outputArray;
1178 }
1179
1180 private function htmlToGutenbergBlock($node) {
1181 $nodeName = strtolower($node->nodeName);
1182
1183 $result = [];
1184
1185 if ($node->nodeType === XML_TEXT_NODE) {
1186 // Text node
1187 return $node->nodeValue;
1188 } else{
1189 if (in_array($nodeName, array('h1', 'h2', 'h3', 'h4', 'h5','h6'))) {
1190 $level = intval(substr($nodeName, 1));
1191 return [
1192 "blockName" => "core/heading",
1193 "attrs" => [
1194 "level" => $level
1195 ],
1196 "innerBlocks" => [],
1197 "innerHTML" => $node->ownerDocument->saveHTML($node),
1198 "innerContent" => [
1199 $node->ownerDocument->saveHTML($node)
1200 ]
1201 ];
1202 } elseif ($nodeName === 'img') {
1203 $src_url = $node->getAttribute('src');
1204 // get alt text from the image tag
1205 $alt_text = $node->getAttribute('alt');
1206 //get title text from the image tag
1207 $title_text = $node->getAttribute('title');
1208 // upload the image to wordpress and the id of the image and url
1209 $attachment_id = $this->common->upload_image_by_url($src_url,$alt_text,$title_text);
1210 // get new source url after upload
1211 $new_src_url = wp_get_attachment_url($attachment_id);
1212
1213
1214 $alt_attr = $node->getAttribute('alt');
1215 $node->setAttribute('alt', $alt_attr !== null ? $alt_attr : '');
1216 $node->setAttribute('src', $src_url);
1217 $node->setAttribute('class', "wp-image-".$attachment_id);
1218 //format the inner content for the image tag
1219 return [
1220 "blockName" => "core/image",
1221 "attrs" => [
1222 "id" => $attachment_id ,
1223 "sizeSlug" => "large",
1224 "linkDestination" => "none"
1225 ],
1226 "innerBlocks" => [],
1227 "innerHTML" => '' ,
1228 "innerContent" => [
1229 sprintf('<figure class="wp-block-image size-large"><img src="%s" alt="%s" class="wp-image-%d" /></figure>',
1230 esc_url($new_src_url),
1231 esc_attr($node->getAttribute('alt')),
1232 $attachment_id
1233 ),
1234 ]
1235 ];
1236 }elseif ($nodeName === 'iframe') {
1237 return [
1238 "blockName" => "core/html",
1239 "attrs" => [],
1240 "innerBlocks" => [],
1241 "innerHTML" => $node->ownerDocument->saveHTML($node) ,
1242 "innerContent" => [
1243 $node->ownerDocument->saveHTML($node)
1244 ]
1245 ];
1246 }elseif ($nodeName === 'p') {
1247 return [
1248 "blockName" => "core/paragraph",
1249 "attrs" => [],
1250 "innerBlocks" => [],
1251 "innerHTML" => $node->ownerDocument->saveHTML($node) ,
1252 "innerContent" => [
1253 $node->ownerDocument->saveHTML($node)
1254 ]
1255 ];
1256 } elseif ($nodeName === 'table') {
1257 $tableHtml = $node->ownerDocument->saveHTML($node);
1258 if($nodeName === 'table'){
1259 $node->setAttribute('class', 'metasyncTable-block');
1260 }
1261 return [
1262 "blockName" => "core/table",
1263 "attrs" => [],
1264 "innerBlocks" => [],
1265 "innerHTML" =>'<figure class="wp-block-table meta-block-tabel">'.$tableHtml.'</figure>',
1266 "innerContent" => [
1267 '<figure class="wp-block-table meta-block-tabel">'.$tableHtml.'</figure>'
1268 ]
1269 ];
1270
1271 }elseif($nodeName === 'ol'||$nodeName === 'ul'){
1272 //list-item
1273 return [
1274 "blockName" => "core/list",
1275 "attrs" => [
1276 'ordered'=> ($nodeName === 'ol'?true:false)
1277 ],
1278 "innerBlocks" => [],
1279 "innerHTML" => $node->ownerDocument->saveHTML($node) ,
1280 "innerContent" => [
1281 $node->ownerDocument->saveHTML($node)
1282 ]
1283 ];
1284 }elseif ($nodeName === 'blockquote') {
1285 # Add the standard Gutenberg quote class to ensure proper styling
1286 $node->setAttribute('class', 'wp-block-quote');
1287 # Convert the full blockquote HTML
1288 $quote_html = $node->ownerDocument->saveHTML($node);
1289 # Return a properly structured Gutenberg "core/quote" block
1290 return [
1291 "blockName" => "core/quote",
1292 "attrs" => [],
1293 "innerBlocks" => [],
1294 "innerHTML" => $quote_html,
1295 "innerContent" => [
1296 $quote_html
1297 ]
1298 ];
1299 }
1300 }
1301
1302 }
1303
1304
1305 private function htmlToDiviBlock($node) {
1306 $result = [];
1307
1308 if ($node->nodeType === XML_TEXT_NODE) {
1309 // Text node
1310 return $node->nodeValue;
1311 } else{
1312 // Element node
1313 $result['id'] = uniqid(); // Generate unique ID for the element
1314 $result['elType'] = 'widget'; // Assume all elements are widgets
1315 if (in_array(strtolower($node->nodeName), array('h1', 'h2', 'h3', 'h4', 'h5','h6'))) {
1316
1317 # Fetch the existing ID from the heading element (if any)
1318 $existing_id = $node->getAttribute('id');
1319
1320 # If ID exists, prepare it as a valid Divi module attribute; otherwise, leave it out
1321 $extra_id_attr = !empty($existing_id) ? ' module_id="' . $existing_id . '"' : '';
1322
1323 # Handle heading elements embedding the ID only when it's available
1324 $result =' [et_pb_heading title="'.$node->nodeValue.'" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" title_level="'.$node->nodeName.'" hover_enabled="0" sticky_enabled="0"'. $extra_id_attr .'][/et_pb_heading]';
1325 } elseif ($node->nodeName === 'img') {
1326 // Handle image elements
1327 try{
1328 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1329 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1330 $result ='[et_pb_image src="'.$node->getAttribute('src') .'" url="'.$node->getAttribute('src'). '" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" hover_enabled="0" global_colors_info="{}" sticky_enabled="0"][/et_pb_image]';
1331
1332 }catch(Error $e){
1333 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1334 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1335 $result ='[et_pb_image src="'.$node->getAttribute('src') .'" url="'.$node->getAttribute('src'). '" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" hover_enabled="0" global_colors_info="{}" sticky_enabled="0"][/et_pb_image]';
1336
1337 error_log(json_encode($e));
1338
1339 }
1340 }elseif($node->nodeName === 'iframe'){
1341 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
1342 }elseif ($node->nodeName === 'p') {
1343 // Handle paragraph elements
1344 $node->setAttribute('class', 'metasyncPara');
1345 $result = '[et_pb_text _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'. $node->ownerDocument->saveHTML($node).'[/et_pb_text]';
1346 } elseif ($node->nodeName === 'table'||$node->nodeName === 'ul' || $node->nodeName === 'ol') {
1347 if($node->nodeName === 'table'){
1348 $node->setAttribute('class', 'metasyncTable');
1349 }
1350 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
1351 }elseif ($node->nodeName === 'blockquote') {
1352 # Add class
1353 $node->setAttribute('class', 'metasyncQuote');
1354 # Convert the <blockquote> node and its contents (including tags like <em>) to HTML
1355 $quote_html = $node->ownerDocument->saveHTML($node);
1356 # Wrap the blockquote content inside a Divi Text Module since Divi has no native quote module
1357 $result = '[et_pb_text _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$quote_html.'[/et_pb_text]';
1358 }
1359 return $result;
1360 }
1361 }
1362 private function diviBlockData($content){
1363 $dom = new DOMDocument();
1364 @$dom->loadHTML($this->encode_numeric_entities($content));
1365
1366 $outputArray = '[et_pb_section fb_built="1" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"][et_pb_row _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"][et_pb_column type="4_4" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]';
1367 foreach ( $dom->getElementsByTagName('*') as $rootElement) {
1368 if($rootElement->nodeName!=='html' && $rootElement->nodeName!=='body' &&
1369 $rootElement->nodeName!=='tbody'&& $rootElement->nodeName!=='tfoot' && $rootElement->nodeName!=='tr' && $rootElement->nodeName!=='th' && $rootElement->nodeName!=='td'){
1370 $htmlArray = $this->htmlToDiviBlock($rootElement);
1371 if(gettype($htmlArray)!=='array'){
1372 $outputArray .= $htmlArray;
1373 }
1374 }
1375 }
1376 $outputArray .='[/et_pb_column][/et_pb_row][/et_pb_section]';
1377 return $outputArray;
1378 }
1379 /*
1380 Added $landing_page_option variable and set default value false to check
1381 if metasync_upload_post_content is called for set_landing_page by following function that are below
1382 # create_item
1383 # update_items
1384 Added $otto_enable variable and set default value false to check
1385 if metasync_upload_post_content is called otto AI landing page by following function that are below
1386 # create_page
1387 # update_page
1388 */
1389 /**
1390 * Upload post content and convert to builder format
1391 *
1392 * This method now delegates to the new HTML to Builder Converter class
1393 * for improved maintainability and CSS preservation.
1394 *
1395 * @param array $item Item data with 'post_content' key
1396 * @param bool $landing_page_option Whether this is for a landing page
1397 * @param bool $otto_enable Whether this is for Otto AI
1398 * @return array Result with 'content' and optional builder meta data
1399 */
1400 public function metasync_upload_post_content($item,$landing_page_option=false,$otto_enable=false)
1401 {
1402 // Load the new converter class
1403 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
1404 $converter = new Metasync_HTML_To_Builder_Converter();
1405
1406 // Delegate to converter's legacy method for backward compatibility
1407 return $converter->convert_legacy($item, $landing_page_option, $otto_enable);
1408 }
1409
1410 public function metasync_handle_post_category($post_id, $post_categories, $append)
1411 {
1412 $post_categories = array_map('sanitize_text_field', $post_categories);
1413 $post_categories = wp_create_categories($post_categories, $post_id);
1414 wp_set_post_categories($post_id, $post_categories, $append);
1415
1416 $categories = get_the_category($post_id);
1417 $fine_categories = array();
1418 foreach ($categories as $category) {
1419 $fine_categories[] = [
1420 "id" => $category->cat_ID,
1421 "name" => $category->name
1422 ];
1423 }
1424 return $fine_categories;
1425 }
1426
1427 public function metasync_set_post_tags($post_id, $post_tags, $append_tags)
1428 {
1429 $post_tags = array_map('sanitize_text_field', $post_tags);
1430 wp_set_post_tags($post_id, $post_tags, $append_tags);
1431
1432 $tags = wp_get_post_tags(
1433 $post_id,
1434 array(
1435 'orderby' => 'name'
1436 )
1437 );
1438
1439 $parse_tags = array();
1440 foreach ($tags as $tag) {
1441 $parse_tags[] = [
1442 "id" => $tag->term_id,
1443 "name" => $tag->name
1444 ];
1445 }
1446 return $parse_tags;
1447 }
1448
1449 public function metasync_handle_hero_image($post_id, $hero_image_url, $hero_image_alt_text)
1450 {
1451 $attachment_id = '';
1452 $hero_image_url = sanitize_url($hero_image_url);
1453 if (filter_var($hero_image_url, FILTER_VALIDATE_URL)) {
1454 $attachment_id = $this->common->upload_image_by_url($hero_image_url);
1455 if ($attachment_id) {
1456 set_post_thumbnail($post_id, $attachment_id);
1457 }
1458 }
1459 if (has_post_thumbnail($post_id) && isset($hero_image_alt_text) && !empty($hero_image_alt_text)) {
1460 $hero_image_id = get_post_thumbnail_id($post_id);
1461 update_post_meta($hero_image_id, '_wp_attachment_image_alt', $hero_image_alt_text);
1462 }
1463 return $attachment_id;
1464 }
1465
1466 /**
1467 * Index a post with Google Indexing API
1468 *
1469 * @param int $post_id WordPress post ID
1470 * @param string $post_type WordPress post type (post, page, etc.)
1471 * @param string $post_status WordPress post status (publish, draft, etc.)
1472 */
1473 public function metasync_google_index_post($post_id, $post_type, $post_status)
1474 {
1475 // Only index published posts/pages
1476 if ($post_status !== 'publish') {
1477 return;
1478 }
1479
1480 // Only index posts and pages (can be extended as needed)
1481 $allowed_post_types = array('post', 'page');
1482 if (!in_array($post_type, $allowed_post_types)) {
1483 return;
1484 }
1485
1486 try {
1487 // Load Google Index functionality if not already loaded
1488 if (!function_exists('google_index_post')) {
1489 $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1490 if (file_exists($google_index_path)) {
1491 require_once $google_index_path;
1492 } else {
1493 error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1494 return;
1495 }
1496 }
1497
1498 // Attempt to index the post with Google
1499 if (function_exists('google_index_post')) {
1500 $result = google_index_post($post_id, $post_type, 'update');
1501
1502 if (isset($result['success']) && $result['success']) {
1503 error_log(sprintf(
1504 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1505 get_the_title($post_id),
1506 $post_id,
1507 $post_type
1508 ));
1509 } else {
1510 error_log(sprintf(
1511 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1512 get_the_title($post_id),
1513 $post_id,
1514 $post_type,
1515 isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1516 ));
1517 }
1518 }
1519 } catch (Exception $e) {
1520 // Log any exceptions but don't break the main functionality
1521 error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1522 }
1523 }
1524
1525 public function create_item($request)
1526 {
1527 // Checking for type of object for response type
1528 $array_response = true;
1529 if (gettype($request) == "object")
1530 $array_response = false;
1531
1532 // Getting JSON Params
1533 $request_data = array($request);
1534 if ($array_response == false)
1535 $request_data = $request->get_json_params();
1536
1537 // Looping for payload for posts
1538 $respCreatePosts = array();
1539 foreach ($request_data as $index => $item) {
1540 $post_author = isset($item['post_author']) ? sanitize_text_field($item['post_author']) : '1';
1541 wp_set_current_user($post_author);
1542 $current_user = wp_get_current_user();
1543 $current_user_id = '1';
1544 if ($current_user->ID > 0) {
1545 $current_user_id = $current_user->ID;
1546 }
1547
1548 $users = get_users(array('role__in' => array('author'), 'fields' => 'ids'));
1549 $post_author = $current_user_id;
1550 if (!empty($users)) {
1551 $key = array_rand($users);
1552 $post_author = $users[$key];
1553 }
1554 /*
1555 check if the create_item is called by set_landing_page function or not
1556 by doing this we will prevent html from going into builder page option
1557 */
1558 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1559 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1560
1561 # Get Current Post type
1562 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
1563 /**
1564 * Check if current theme is Flatsome using SAVED theme info (not wp_get_theme())
1565 * Theme info is saved by admin hooks, so no security triggers during REST API
1566 */
1567 $metasync_general = Metasync::get_option('general');
1568 $theme_name = $metasync_general['current_theme_name'] ?? '';
1569 $theme_template = $metasync_general['current_theme_template'] ?? '';
1570
1571 $is_flatsome_theme = false;
1572 if (!empty($theme_name) && stripos($theme_name, 'Flatsome') !== false) {
1573 $is_flatsome_theme = true;
1574 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1575 $is_flatsome_theme = true;
1576 }
1577
1578 # Skip title/image prepending for Flatsome (it displays them by default)
1579 if (!$is_flatsome_theme) {
1580 # Get the setting for the post template
1581 $title_and_feature_image = $this->append_content_if_missing_elements($current_post_type);
1582
1583 # Check if the post title is there in the template or not
1584 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1585
1586 # Prepend the feature image
1587 $item['post_content'] = '<img src="'.$item['hero_image_url'].'" />'.$item['post_content'] ;
1588 }
1589
1590 # Check if the post title is there in the template or not
1591 if(!$title_and_feature_image['title_in_headings']){
1592
1593 # Prepend the post title
1594 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1595 }
1596
1597 }
1598 # This will be used by create_page function
1599 $content = $this->metasync_upload_post_content($item,false,false);
1600 }elseif(isset($item['is_landing_page']) && $item['is_landing_page'] == true){
1601 $content = $this->metasync_upload_post_content($item,true); // This will be used by set_landing_page function
1602 }
1603 /*
1604 Check if the otto_ai_page is payload is set in the api or not.
1605 If it is please set the third parameter to true.
1606 */
1607 if($isOttoAiPage && !empty($item['style_data'])){
1608 $content = $this->metasync_upload_post_content($item,true,true);
1609 }
1610
1611 $new_post = array(
1612 'post_author' => $post_author,
1613 'post_title' => sanitize_text_field($item['post_title']),
1614 'post_content' => $content['content'] ? $content['content'] : $item['post_content'],
1615 'post_excerpt' => isset($item['meta_description']) ? sanitize_text_field($item['meta_description']) : '',
1616 'post_type' => isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post',
1617 'post_status' => isset($item['post_status']) ? sanitize_text_field($item['post_status']) : 'publish',
1618 'comment_status' => isset($item['comment_status']) ? sanitize_text_field($item['comment_status']) : 'open',
1619 'post_parent' => isset($item['post_parent']) ?$item['post_parent'] : 0
1620 );
1621
1622 if (isset($item['post_author']) && !empty($item['post_author'])) {
1623 $new_post['post_author'] = sanitize_text_field($item['post_author']);
1624 }
1625
1626 // adding custom permalink
1627 if (isset($item['permalink']) && !empty($item['permalink'])) {
1628 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1629 }
1630
1631 if (isset($item['post_date']) && !empty($item['post_date'])) {
1632 $is_valid_date = date('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1633 if (!$is_valid_date) {
1634 return new WP_Error(
1635 'rest_post_invalid_date',
1636 esc_html__('Post date is not valid'),
1637 array('status' => 400)
1638 );
1639 }
1640
1641 // $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
1642 // $post_date_str = strtotime($item['post_date']);
1643 // if ($date_limit_str >= $post_date_str) {
1644 // $newDate = date('Y-m-d', strtotime('-2 month'));
1645 // return new WP_Error(
1646 // 'rest_post_greater_date',
1647 // esc_html__("Post date should be greater then " . $newDate),
1648 // array('status' => 400)
1649 // );
1650 // }
1651
1652 // if ($post_date_str > strtotime(date('Y-m-d'))) {
1653 // return new WP_Error(
1654 // 'rest_post_less_date',
1655 // esc_html__("Post date should be less then Today"),
1656 // array('status' => 400)
1657 // );
1658 // }
1659
1660 // $new_post['post_date'] = sanitize_text_field($item['post_date'] . date(' h:i:s'));
1661 }
1662
1663 // Adding condition to check if the post is already exist
1664 $post_status_new = isset($item['post_status']) ? sanitize_text_field($item['post_status']) : 'publish';
1665 $post_permalink = $item['permalink'] = isset($item['permalink']) ? $item['permalink'] : sanitize_title($new_post['post_title']);
1666
1667 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1668 # Fix to avoid PHP error if the get_page_by_path returns null
1669 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1670 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1671 if ($getPostID_byURL == NULL) {
1672 // check if the post_title is set and not empty when called by otto_ai_page
1673 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1674 $getPostID_byURL = new WP_Query(
1675 array(
1676 'post_type' => $new_post['post_type'],
1677 'title' => $new_post['post_title']
1678 )
1679 );
1680 $getPostID_byURL = $getPostID_byURL->posts[0]->ID ?? null;
1681 }
1682 }
1683
1684 // Allow HTML code for landing page
1685 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1686 kses_remove_filters();
1687 }
1688
1689 if (isset($item['post_parent']) && !empty($item['post_parent']) && $item['post_parent'] != 0) {
1690 if ($new_post['post_type'] == 'page') {
1691 $new_post['post_parent'] = isset($item['post_parent']) ? $item['post_parent'] : 0;
1692 } else {
1693 $item['post_parent'] = isset($item['post_parent']) ? $item['post_parent'] : 0;
1694 }
1695 }
1696
1697 if ($getPostID_byURL === NULL) {
1698 $post_id = wp_insert_post($new_post);
1699 $permalink = get_permalink($post_id);
1700
1701 # If the post was successfully created (no WP error)
1702 if (!is_wp_error($post_id)) {
1703
1704 # Add a custom meta field
1705 update_post_meta($post_id, 'metasync_post', 'yes');
1706 }
1707
1708 } else {
1709 $new_post['ID'] = $post_id = $getPostID_byURL;
1710 wp_update_post($new_post);
1711 unset($new_post['ID']);
1712 $permalink = get_permalink($post_id);
1713 }
1714
1715 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1716 kses_remove_filters();
1717 }
1718
1719 $post_meta = array();
1720 if(isset($content['elementor_meta_data'])){
1721 $post_meta = array_merge($post_meta,$content['elementor_meta_data']);
1722 }else if(isset($content['divi_meta_data'])){
1723 $post_meta = array_merge($post_meta,$content['divi_meta_data']);
1724 $post_meta['_et_pb_ab_current_shortcode']='[et_pb_split_track id="'.$post_id.'" /]';
1725 $post_meta['_et_pb_use_builder']='on';
1726 $post_meta['_et_pb_built_for_post_type']=isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
1727 }
1728
1729 if (isset($item['meta_description']) && !empty($item['meta_description'])) {
1730 $post_meta['meta_description'] = sanitize_text_field($item['meta_description']);
1731 }
1732 if (isset($item['meta_robots']) && !empty($item['meta_robots'])) {
1733 $post_meta['meta_robots'] = sanitize_text_field($item['meta_robots']);
1734 }
1735
1736 // Add custom field for post header section
1737 if (isset($item['custom_post_header'])) { // && !empty($item['custom_post_header'])
1738 $post_meta['custom_post_header'] = $item['custom_post_header'];
1739 }
1740 // Add custom field for post footer section
1741 if (isset($item['custom_post_footer'])) { // && !empty($item['custom_post_footer'])
1742 $post_meta['custom_post_footer'] = $item['custom_post_footer'];
1743 }
1744
1745 // Add custom field for searchatlas top
1746 if (isset($item['searchatlas_embed_top'])) { // && !empty($item['searchatlas_embed_top'])
1747 $post_meta['searchatlas_embed_top'] = $item['searchatlas_embed_top'];
1748 }
1749 // Add custom field for searchatlas bottom
1750 if (isset($item['searchatlas_embed_bottom'])) { // && !empty($item['searchatlas_embed_bottom'])
1751 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1752 }
1753
1754 // Add custom fields to posts and pages
1755 foreach ($post_meta as $key => $value) {
1756 // if (!empty($value) && !is_null($value)) {
1757 add_post_meta($post_id, $key, $value, true);
1758 // }
1759 }
1760
1761
1762 $attachment_id = '';
1763 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
1764 $attachment_id = $this->metasync_handle_hero_image($post_id, $item['hero_image_url'], $item['hero_image_alt_text']);
1765 }
1766
1767 $redirection = array();
1768 if (isset($item['redirection_enable']) && !empty($item['redirection_enable'])) {
1769 $redirection['enable'] = sanitize_text_field($item['redirection_enable']);
1770 }
1771 if (isset($item['redirection_type']) && !empty($item['redirection_type'])) {
1772 $redirection['type'] = sanitize_text_field($item['redirection_type']);
1773 }
1774 if (isset($item['redirection_url']) && !empty($item['redirection_url'])) {
1775 $redirection['url'] = sanitize_url($item['redirection_url']);
1776 }
1777 if (!empty($redirection)) {
1778 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
1779 }
1780
1781 $post_cattegories = [];
1782 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
1783
1784 # fixed Undefined array key issue
1785 if ($new_post['post_type'] === 'post' && isset($item['post_categories']) && is_array($item['post_categories'])) {
1786 $append_categories = isset($item['append_categories']) && $item['append_categories'] == true ? true : false;
1787 $post_cattegories = $this->metasync_handle_post_category($post_id, $item['post_categories'], $append_categories);
1788 }
1789 if (isset($content['elementor_meta_data']) && did_action( 'elementor/loaded' )) {
1790 // Clear Elementor cache for the specified post ID
1791 \Elementor\Plugin::instance()->files_manager->clear_cache();
1792
1793 }
1794
1795 $post_tags = [];
1796 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_tags'])) {
1797
1798 # fixed Undefined array key 'post_tags issue
1799 if ($new_post['post_type'] === 'post' && isset($item['post_tags']) && is_array($item['post_tags'])) {
1800 $append_tags = isset($item['append_tags']) && $item['append_tags'] == true ? true : false;
1801 $post_tags = $this->metasync_set_post_tags($post_id, $item['post_tags'], $append_tags);
1802 }
1803
1804 $new_post['post_categories'] = $post_cattegories;
1805 $new_post['post_tags'] = $post_tags;
1806 unset($new_post['post_name']);
1807 $new_post['post_id'] = $post_id;
1808 $new_post['permalink'] = $permalink;
1809 $new_post['hero_image_url'] = wp_get_attachment_url($attachment_id);
1810 $new_post['hero_image_alt_text'] = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
1811
1812 # Log sync history for Content Genius post creation/update
1813 if (!is_wp_error($post_id) && $post_id > 0) {
1814 $action = ($getPostID_byURL === NULL) ? 'Created' : 'Updated';
1815 $title_preview = mb_strlen($new_post['post_title']) > 30 ? mb_substr($new_post['post_title'], 0, 30) . '...' : $new_post['post_title'];
1816
1817 # Use appropriate title based on post type
1818 $content_type_label = ($new_post['post_type'] === 'page') ? 'Page' : 'Post';
1819 $sync_status = ($new_post['post_status'] === 'publish') ? 'published' : $new_post['post_status'];
1820
1821 metasync_log_sync_history([
1822 'title' => "{$content_type_label} {$action} ({$title_preview})",
1823 'source' => 'Content Genius',
1824 'status' => $sync_status,
1825 'content_type' => ucfirst($new_post['post_type']),
1826 'url' => $permalink,
1827 'meta_data' => json_encode([
1828 'post_id' => $post_id,
1829 'post_title' => $new_post['post_title'],
1830 'post_type' => $new_post['post_type'],
1831 'post_status' => $new_post['post_status'],
1832 'action' => strtolower($action)
1833 ])
1834 ]);
1835
1836 # Track Content Genius event in GA4
1837 try {
1838 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
1839 } catch (Exception $e) {
1840 error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
1841 }
1842
1843 // Google Indexing Integration
1844 $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
1845 }
1846
1847 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
1848 ksort($respCreatePosts[$index]);
1849 }
1850
1851 if ($array_response == false)
1852 return rest_ensure_response($respCreatePosts);
1853 return $respCreatePosts;
1854 }
1855
1856 public function set_landing_page($request)
1857 {
1858 $params = $request->get_json_params();
1859
1860 if (!isset($params[0]) || empty($params[0])) {
1861 return new WP_Error(
1862 'validation_error',
1863 'Invalid request data. Empty Payload Provided',
1864 array('status' => 400)
1865 );
1866 }
1867
1868 $payload = $params[0];
1869 $payload['permalink'] = "metasync-landing-page"; // hardcoding to avoid duplicates
1870 $payload['post_type'] = "page";
1871 $payload['post_status'] = "publish";
1872 $payload['is_landing_page'] = true;
1873 $createPages = $this->create_item($payload); // creating landing page
1874
1875 if (is_wp_error($createPages)) {
1876 return $createPages;
1877 }
1878
1879 $post_id = $createPages[0]['post_id'];
1880 update_option('page_on_front', $post_id);
1881 update_option('show_on_front', 'page');
1882
1883 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
1884 update_post_meta($post_id, '_wp_page_template', Metasync_Template::TEMPLATE_NAME);
1885 return rest_ensure_response($createPages);
1886 }
1887
1888 public function delete_item()
1889 {
1890 $get_data = metasync_sanitize_input_array($_GET);
1891 if (!isset($get_data['ID']))
1892 return false;
1893
1894 $post_id = sanitize_text_field($get_data['ID']) ?? null;
1895 $post = get_post($post_id);
1896 if ($post) {
1897 wp_delete_post($post_id);
1898 return new WP_Error(
1899 'rest_post_delete_success',
1900 esc_html__(''),
1901 // HTTP 204 requires no body for response
1902 array('status' => 204)
1903 );
1904 }
1905 return new WP_Error(
1906 'rest_post_delete_fail',
1907 esc_html__('No post found in the database with requested ID.'),
1908 array('status' => 400)
1909 );
1910 }
1911
1912 public function get_items($request)
1913 {
1914 $get_data = metasync_sanitize_input_array($_GET);
1915
1916 # let us check if request send post id and is valid int
1917 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
1918
1919 #get the post id
1920 $post_id = $get_data['post_id'];
1921
1922 #get the elementor items of the post
1923 $elementor_items = $this->elementor_getItems($post_id);
1924
1925 #return the elementor items in response
1926 return rest_ensure_response($elementor_items);
1927 }
1928
1929 return rest_ensure_response(
1930 array(
1931 'posts' => $this->filter_post_attributes(
1932 get_posts(
1933 array(
1934 'numberposts' => -1
1935 )
1936 )
1937 ),
1938 'pages' => $this->filter_post_attributes(
1939 get_pages(
1940 array(
1941 'numberposts' => -1
1942 )
1943 )
1944 )
1945 )
1946 );
1947 }
1948
1949 private function elementor_getItems($post_id)
1950 {
1951 $data_array = array();
1952 $elementorData = get_post_meta($post_id, '_elementor_data', true);
1953 if (!empty($elementorData)) {
1954 $elementorData = json_decode($elementorData);
1955 $this->elementor_getElement($elementorData, $data_array);
1956 }
1957 // echo $this->elementor_convertToXML($data_array);
1958 return $this->elementor_convertToDraftJS($data_array);
1959 }
1960
1961 private function elementor_getElement($elements, &$data_array)
1962 {
1963 $elements_allowedWidgetTypes = ['heading', 'text-editor', 'image'];
1964 $elements_groupItems = ['section', 'column'];
1965 foreach ($elements as $element) {
1966 if (in_array($element->elType, $elements_groupItems)) {
1967 $this->elementor_getElement($element->elements, $data_array);
1968 continue;
1969 }
1970
1971 #check that we process only widgets
1972 if($element->elType !== 'widget'){
1973
1974 #go to next
1975 continue;
1976 }
1977
1978 switch ($element->widgetType) {
1979 case 'heading':
1980 $data_array[$element->id] = ['value' => trim($element->settings->title), 'type' => 'heading'];
1981 break;
1982 case 'image':
1983 $data_array[$element->id] = ['value' => $element->settings->image->url, 'type' => 'url'];
1984 break;
1985 case 'text-editor':
1986 $data_array[$element->id] = ['value' => trim($element->settings->editor), 'type' => 'text-editor'];
1987 break;
1988
1989 default:
1990 }
1991 }
1992 }
1993
1994 private function elementor_convertToDraftJS($data_array)
1995 {
1996 $response = array(
1997 "blocks" => [],
1998 );
1999
2000 foreach ($data_array as $id => $item) {
2001 // array_push($response['blocks'],
2002 // array(
2003 // "key" => "$id",
2004 // "text" => $item['value'],
2005 // "type" => "unstyled",
2006 // "depth" => 0,
2007 // "inlineStyleRanges" => [],
2008 // "entityRanges" => [],
2009 // "data" => []
2010 // )
2011 // );
2012 $this->convertFromHTMLToContentBlocks($id, $item['value'], $response['blocks']);
2013 }
2014 return $response;
2015 }
2016
2017 private function convertFromHTMLToContentBlocks($key, $html, &$contentBlocks)
2018 {
2019 $dom = new DOMDocument();
2020 libxml_use_internal_errors(true); // Disable error reporting for HTML5 tags
2021 $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
2022 libxml_use_internal_errors(false); // Enable error reporting again
2023
2024 $blockLevel = [];
2025 // Iterate through each node in the body
2026 foreach ($dom->getElementsByTagName('*')->item(0)->childNodes as $node) {
2027 // Process each node and convert it to a content block
2028 $block = $this->convertNodeToContentBlock($key, $node, $blockLevel);
2029 if ($block) {
2030 $contentBlocks[] = $block;
2031 }
2032 }
2033 // return $contentBlocks;
2034 }
2035
2036 private function convertNodeToContentBlock($id, $node, &$blockLevel)
2037 {
2038 if($blockLevel[$id] == null) {
2039 $blockLevel[$id] = 0;
2040 }
2041 $blockLevel[$id]++;
2042 $id = $id . '-' . $blockLevel[$id];
2043
2044
2045 // Check node type
2046 switch ($node->nodeType) {
2047 case XML_TEXT_NODE:
2048 // Text node
2049 $text = trim($node->nodeValue);
2050 if ($text !== '') {
2051 return [
2052 'key' => $id,
2053 'type' => 'unstyled',
2054 'text' => $text,
2055 'depth' => 0,
2056 'inlineStyleRanges' => [],
2057 'entityRanges' => [],
2058 'data' => [],
2059 ];
2060 }
2061 break;
2062
2063 case XML_ELEMENT_NODE:
2064 // Element node
2065 $tagName = strtolower($node->tagName);
2066
2067 // Map HTML tags to Draft.js block types
2068 $blockTypeMap = [
2069 'p' => 'unstyled',
2070 'h1' => 'header-one',
2071 'h2' => 'header-two',
2072 'h3' => 'header-three',
2073 // Add more block types as needed
2074 ];
2075
2076 $blockType = isset($blockTypeMap[$tagName]) ? $blockTypeMap[$tagName] : $tagName; //'unstyled';
2077
2078 $block = [
2079 'key' => $id,
2080 'type' => $blockType,
2081 'text' => '',
2082 'depth' => 0,
2083 'inlineStyleRanges' => [],
2084 'entityRanges' => [],
2085 'data' => [],
2086 ];
2087
2088 // Process child nodes recursively
2089 foreach ($node->childNodes as $childNode) {
2090 $childBlock = $this->convertNodeToContentBlock($id, $childNode, $blockLevel);
2091 if ($childBlock) {
2092 // Append child block's text and inline styles
2093 $block['text'] .= $childBlock['text'];
2094 $block['inlineStyleRanges'] = array_merge(
2095 $block['inlineStyleRanges'],
2096 $childBlock['inlineStyleRanges']
2097 );
2098 }
2099 }
2100
2101 // Handle inline styles
2102 $inlineStyleMap = [
2103 'strong' => 'BOLD',
2104 'em' => 'ITALIC',
2105 // Add more inline styles as needed
2106 ];
2107
2108 if (isset($inlineStyleMap[$tagName])) {
2109 $inlineStyle = $inlineStyleMap[$tagName];
2110 $startIndex = strlen($block['text']);
2111 $endIndex = $startIndex + strlen($node->textContent);
2112
2113 $block['inlineStyleRanges'][] = [
2114 'offset' => $startIndex,
2115 'length' => $endIndex - $startIndex,
2116 'style' => $inlineStyle,
2117 ];
2118 }
2119
2120 return $block;
2121 }
2122
2123 return null;
2124 }
2125
2126
2127 private function update_object($object_id, $update_params)
2128 {
2129 $post_params = ['ID' => $object_id];
2130
2131 if (!empty($update_params['post_title']) && !is_null($update_params['post_title'])) {
2132 $post_params['post_title'] = $update_params['post_title'];
2133 unset($update_params['post_title']);
2134 }
2135 if (!empty($update_params['post_excerpt']) && !is_null($update_params['post_excerpt'])) {
2136 $post_params['post_excerpt'] = $update_params['post_excerpt'];
2137 unset($update_params['post_excerpt']);
2138 }
2139 if (!empty($update_params['post_content']) && !is_null($update_params['post_content'])) {
2140 $post_params['post_content'] = $update_params['post_content'];
2141 unset($update_params['post_content']);
2142 }
2143 if (!empty($update_params['post_status']) && !is_null($update_params['post_status'])) {
2144 $post_params['post_status'] = $update_params['post_status'];
2145 unset($update_params['post_status']);
2146 }
2147 if (!empty($update_params['post_name']) && !is_null($update_params['post_name'])) {
2148 $post_params['post_name'] = $update_params['post_name'];
2149 unset($update_params['post_name']);
2150 }
2151 if (!empty($update_params['post_category']) && !is_null($update_params['post_category'])) {
2152 $post_params['post_category'] = $update_params['post_category'];
2153 unset($update_params['post_category']);
2154 }
2155 if (!empty($update_params['post_author']) && !is_null($update_params['post_author'])) {
2156 $post_params['post_author'] = $update_params['post_author'];
2157 unset($update_params['post_author']);
2158 }
2159 if (!empty($update_params['comment_status']) && !is_null($update_params['comment_status'])) {
2160 $post_params['comment_status'] = $update_params['comment_status'];
2161 unset($update_params['comment_status']);
2162 }
2163 if (!empty($update_params['post_date']) && !is_null($update_params['post_date'])) {
2164 $post_params['post_date'] = $update_params['post_date'];
2165 unset($update_params['post_date']);
2166 }
2167 if (!empty($update_params['post_parent']) && !is_null($update_params['post_parent'])) {
2168 $update_params['post_parent'] = isset($update_params['post_parent']) ? $update_params['post_parent']: 0;
2169 $post_params['post_parent'] = $update_params['post_parent'];
2170 unset($update_params['post_parent']);
2171 }
2172 // Update Post and Page content
2173
2174 $tryUpdatePost = wp_update_post($post_params);
2175 // Update Elementor post content
2176 // $this->elementor_update_content($object_id, $post_params['post_content']);
2177 // Update Post and Page meta data
2178 $resp_meta = array(
2179 'post_content' => false,
2180 'post_meta' => array()
2181 );
2182
2183 if ($tryUpdatePost !== 0 && $tryUpdatePost !== false)
2184 $resp_meta['post_content'] = true;
2185
2186 foreach ($update_params as $key => $value) {
2187 // var_dump($object_id, $key, $value);
2188 // if (!empty($value) && !is_null($value)) {
2189 $response = update_post_meta($object_id, $key, $value);
2190 if ($response == false) {
2191 $resp_meta['post_meta'][$object_id][$key] = 'NO_CHANGE';
2192 } else {
2193 $resp_meta['post_meta'][$object_id][$key] = 'UPDATED'; //$response;
2194 }
2195 // }
2196 }
2197 return $resp_meta;
2198 }
2199
2200 public function update_items($request)
2201 {
2202 $data = array();
2203
2204 $array_response = true;
2205 if (gettype($request) == "object")
2206 $array_response = false;
2207
2208 $request_data = array($request);
2209 if ($array_response == false)
2210 $request_data = $request->get_json_params();
2211
2212 foreach ($request_data as $post) {
2213 $update_params = array();
2214 $post_id = 0;
2215
2216 // Gettin post id from payload
2217 if ($post_id == 0 && isset($post['post_id']) && !empty($post['post_id'])) {
2218 $post_id = sanitize_text_field($post['post_id']);
2219 } else {
2220 // Getting post id via URL
2221 if (isset($post['post_url']) && !empty($post['post_url'])) {
2222 $safe_url = sanitize_url($post['post_url']);
2223 $post_id = url_to_postid($safe_url);
2224
2225 if ($post_id == 0) {
2226 // try to get post_id by permalink
2227 # $post_id = @get_page_by_path(sanitize_text_field($post['permalink']), OBJECT, 'post')->ID;
2228 # Fix to avoid PHP error if the get_page_by_path returns null
2229 $post_by_path = @get_page_by_path(sanitize_text_field($post['permalink']), OBJECT, 'post');
2230 $post_id = $post_by_path ? $post_by_path->ID : 0;
2231 }
2232
2233 if ($post_id == 0) {
2234 // try to get permalink from URL
2235 $url_to_permalink = $this->common->get_permalink_from_url($safe_url);
2236 # $post_id = @get_page_by_path($url_to_permalink, OBJECT, 'post')->ID;
2237 # Fix to avoid PHP error if the get_page_by_path returns null
2238 $post_by_path = @get_page_by_path($url_to_permalink, OBJECT, 'post');
2239 $post_id = $post_by_path ? $post_by_path->ID : 0;
2240 }
2241 }
2242 }
2243 if(!$array_response){
2244 $post_data = get_post($post['post_id']);
2245 if ($post_data->post_type !== $post['post_type']) {
2246 if ($post_data) {
2247 $post_data->post_type = 'post';
2248 wp_update_post($post_data);
2249 }
2250 }
2251 }
2252 $post_data = get_post($post_id);
2253 if (!$post_data) {
2254 return new WP_Error(
2255 'rest_post_update_fail',
2256 esc_html__('No post found in the database with requested ID.'),
2257 array('status' => 400)
2258 );
2259 }
2260
2261 $post_author = isset($post['post_author']) && !empty($post['post_author']) ?
2262 $update_params['post_author'] = sanitize_text_field($post['post_author']) :
2263 $update_params['post_author'] = $post_data->post_author;
2264 wp_set_current_user($post_author);
2265
2266 if (isset($post['post_title']) && !empty($post['post_title'])) {
2267 $update_params['post_title'] = sanitize_text_field($post['post_title']);
2268 }
2269 if (isset($post['post_parent']) && !empty($post['post_parent'])) {
2270 $update_params['post_parent'] = (int) $post['post_parent'];
2271 }
2272 if (isset($post['meta_description']) && !empty($post['meta_description'])) {
2273 $get_desc_meta = get_post_meta($post['post_id'], 'meta_description', true);
2274 $update_params['meta_description'] = $post['meta_description'] ?
2275 sanitize_text_field($post['meta_description']) : $get_desc_meta;
2276 }
2277 if (isset($post['meta_robots']) && !empty($post['meta_robots'])) {
2278 $get_robots_meta = get_post_meta($post['post_id'], 'meta_robots', true);
2279 $update_params['meta_robots'] = $post['meta_robots'] ?
2280 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2281 }
2282 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2283 $update_params['meta_canonical'] = sanitize_text_field($post['meta_canonical']);
2284 }
2285
2286 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2287 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2288
2289 # Above we are updating the post_type so we have to get latest value that has been change on the server
2290 $post_fresh_data = get_post($post['post_id']);
2291
2292 /**
2293 * Check if current theme is Flatsome using SAVED theme info (not wp_get_theme())
2294 * Theme info is saved by admin hooks, so no security triggers during REST API
2295 */
2296 $metasync_general = Metasync::get_option('general');
2297 $theme_name = $metasync_general['current_theme_name'] ?? '';
2298 $theme_template = $metasync_general['current_theme_template'] ?? '';
2299
2300 $is_flatsome_theme = false;
2301 if (!empty($theme_name) && stripos($theme_name, 'Flatsome') !== false) {
2302 $is_flatsome_theme = true;
2303 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
2304 $is_flatsome_theme = true;
2305 }
2306
2307 # Skip title/image prepending for Flatsome (it displays them by default)
2308 if (!$is_flatsome_theme) {
2309 # Get the setting for the post template
2310 $title_and_feature_image = $this->append_content_if_missing_elements($post_fresh_data->post_type);
2311
2312 # Check if the post title is there in the template or not
2313 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2314
2315 # Prepend the feature image
2316 $post['post_content'] = '<img src="'.$post['hero_image_url'].'" />'.$post['post_content'] ;
2317 }
2318
2319 # Check if the post title is there in the template or not
2320 if(!$title_and_feature_image['title_in_headings']){
2321
2322 # Prepend the post title
2323 $post['post_content'] = '<h1>'.$post['post_title'].'</h1>'.$post['post_content'] ;
2324 }
2325 }
2326 // This will be used by update_page function
2327 $content = $this->metasync_upload_post_content($post,false,false);
2328 $update_params['post_content'] = $content['content'];
2329 }
2330 /*
2331 check if the create_item is called by set_landing_page function or not
2332 by doing this we will prevent html from going into builder page option
2333 */
2334 if($isOttoAiPage){
2335 $content = $this->metasync_upload_post_content($post,true,true);
2336 $update_params['post_content'] = $content['content'];
2337 // delete the elementor related meta data so that it won't get proccess by elementor
2338 delete_post_meta( $post_id, '_elementor_data' );
2339 delete_post_meta( $post_id, '_elementor_version' );
2340 delete_post_meta( $post_id, '_elementor_css' );
2341 delete_post_meta( $post_id, '_elementor_page_assets' );
2342 }
2343
2344 // Add custom field for post header section
2345 if (isset($post['custom_post_header'])) { // && !empty($post['custom_post_header'])
2346 $update_params['custom_post_header'] = $post['custom_post_header'];
2347 }
2348
2349 if (isset($post['custom_post_footer'])) { // && !empty($post['custom_post_footer'])
2350 $update_params['custom_post_footer'] = $post['custom_post_footer'];
2351 }
2352
2353 if (isset($post['searchatlas_embed_top'])) { // && !empty($post['searchatlas_embed_top'])
2354 $update_params['searchatlas_embed_top'] = $post['searchatlas_embed_top'];
2355 }
2356
2357 if (isset($post['searchatlas_embed_bottom'])) { // && !empty($post['searchatlas_embed_bottom'])
2358 $update_params['searchatlas_embed_bottom'] = $post['searchatlas_embed_bottom'];
2359 }
2360
2361 if (isset($post['meta_description']) && !empty($post['meta_description'])) {
2362 $update_params['post_excerpt'] = sanitize_text_field($post['meta_description']);
2363
2364 }
2365
2366 if (isset($post['post_status']) && !empty($post['post_status'])) {
2367 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2368 $permalink = get_permalink($post_id);
2369 }
2370 if (isset($post['permalink']) || !empty($post['permalink'])) {
2371 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2372 }
2373 if (isset($post['post_parent']) ) {
2374 $update_params['post_parent'] = isset($post['post_parent']) ? sanitize_text_field($post['post_parent']) : 0;
2375
2376 wp_update_post(
2377 array(
2378 'ID' =>$post_id,
2379 'post_parent' => $update_params['post_parent']
2380 )
2381 );
2382 }
2383
2384
2385 if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2386 $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2387 if (!$is_valid_date) {
2388 return new WP_Error(
2389 'rest_post_invalid_date',
2390 esc_html__('Post date is not valid'),
2391 array('status' => 400)
2392 );
2393 }
2394
2395 $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2396 $post_date_str = strtotime($post['post_date']);
2397
2398 if ($date_limit_str >= $post_date_str) {
2399 $newDate = date('Y-m-d', strtotime('-2 month'));
2400 return new WP_Error(
2401 'rest_post_greater_date',
2402 esc_html__("Post date should be greater then " . $newDate),
2403 array('status' => 400)
2404 );
2405 }
2406
2407 if ($post_date_str > strtotime(date('Y-m-d'))) {
2408 return new WP_Error(
2409 'rest_post_greater_date',
2410 esc_html__('Post date should be less then Today'),
2411 array('status' => 400)
2412 );
2413 }
2414 $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2415 }
2416
2417 $post_cattegories = [];
2418 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2419
2420 # fixed Undefined array key issue
2421 if ($post_data && $post_data->post_type === 'post' && isset($post['post_categories']) && is_array($post['post_categories'])) {
2422 $append_categories = isset($post['append_categories']) && $post['append_categories'] == true ? true : false;
2423 $post_cattegories = $this->metasync_handle_post_category($post_id, $post['post_categories'], $append_categories);
2424 }
2425
2426 $post_tags = [];
2427 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_tags'])) {
2428
2429 # fixed Undefined array key 'post_tags' issue
2430 if ($post_data && $post_data->post_type === 'post' && isset($post['post_tags']) && is_array($post['post_tags'])) {
2431 $append_tags = isset($post['append_tags']) && $post['append_tags'] == true ? true : false;
2432 $post_tags = $this->metasync_set_post_tags($post_id, $post['post_tags'], $append_tags);
2433 }
2434
2435 $attachment_id = '';
2436 if (isset($post['hero_image_url']) && !empty($post['hero_image_url'])) {
2437 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2438 }
2439
2440 $resp_update = $this->update_object($post_id, $update_params);
2441 if(isset($content['elementor_meta_data'])){
2442 foreach ($content['elementor_meta_data'] as $key => $value) {
2443 update_post_meta($post_id, $key, $value);
2444 }
2445 if ( did_action( 'elementor/loaded' ) ) {
2446 // Clear Elementor cache for the specified post ID
2447 \Elementor\Plugin::instance()->files_manager->clear_cache();
2448 }
2449 } elseif (!$isOttoAiPage && get_post_meta($post_id, '_elementor_data', true)) {
2450 // Content was NOT converted to Elementor format (e.g. Oxygen is the active
2451 // builder), but stale Elementor meta exists from a previous sync. Clear it
2452 // so Elementor doesn't override the page builder's rendering.
2453 delete_post_meta($post_id, '_elementor_data');
2454 delete_post_meta($post_id, '_elementor_edit_mode');
2455 delete_post_meta($post_id, '_elementor_version');
2456 delete_post_meta($post_id, '_elementor_css');
2457 delete_post_meta($post_id, '_elementor_page_assets');
2458 delete_post_meta($post_id, '_elementor_page_settings');
2459 }
2460
2461 $redirection = array();
2462 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2463 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2464 }
2465 if (!empty($post['redirection_type']) && !is_null($post['redirection_type'])) {
2466 $redirection['type'] = sanitize_text_field($post['redirection_type']);
2467 }
2468 if (!empty($post['redirection_url']) && !is_null($post['redirection_url'])) {
2469 $redirection['url'] = sanitize_url($post['redirection_url']);
2470 }
2471 if (!empty($redirection)) {
2472 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2473 }
2474
2475
2476 $post_revisions = wp_get_post_revisions($post_id);
2477 // Sync post categories to customer dashboard
2478 $this->lgSendCustomerPostParams();
2479
2480 unset($update_params['post_name']);
2481 unset($update_params['post_category']);
2482
2483 $update_params['post_categories'] = $post_cattegories;
2484 $update_params['post_tags'] = $post_tags;
2485 $update_params['post_id'] = (int) $post_id;
2486 $update_params['permalink'] = $permalink;
2487
2488 $update_params['hero_image_url'] = wp_get_attachment_url($attachment_id);
2489 $update_params['hero_image_alt_text'] = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2490 $update_params['post_revisions'] = gettype($post_revisions) == 'array' ? count($post_revisions) : (int)$post_revisions;
2491 $update_params['post_updated'] = $resp_update;
2492
2493
2494 // check if the content is added or not
2495 if(empty($post['is_landing_page']) ){
2496
2497 # update the content
2498 $postContent = array(
2499 'ID' => $post_id,
2500 'post_content' => ($content['content'] ? $content['content'] : $post['post_content']),
2501 );
2502 wp_update_post($postContent );
2503 #rename the variable to avoide confusion
2504 $post_meta_data = array();
2505 if(isset($content['elementor_meta_data'])){
2506 $post_meta_data = array_merge($post_meta_data,$content['elementor_meta_data']);
2507 }else if(isset($content['divi_meta_data'])){
2508 $post_meta_data = array_merge($post_meta_data,$content['divi_meta_data']);
2509
2510 }
2511 # update the content
2512 // add and update the post meta
2513 foreach ($post_meta_data as $key => $value) {
2514 // if (!empty($value) && !is_null($value)) {
2515
2516 update_post_meta($post_id, $key, $value);
2517 //
2518 }
2519 #check if the elementor plugin is active
2520 if ( did_action( 'elementor/loaded' ) ) {
2521 # Clear Elementor cache for the specified post ID
2522 \Elementor\Plugin::instance()->files_manager->clear_cache();
2523
2524 }
2525 }
2526 # Log sync history for Content Genius post update
2527 if ($post_id > 0 && !empty($update_params)) {
2528 $post_title = $update_params['post_title'] ?? $post_data->post_title ?? 'Untitled';
2529 $title_preview = mb_strlen($post_title) > 30 ? mb_substr($post_title, 0, 30) . '...' : $post_title;
2530 $post_status = $update_params['post_status'] ?? $post_data->post_status ?? 'draft';
2531 $post_type = $post_data->post_type ?? 'post';
2532
2533 # Use appropriate title based on post type
2534 $content_type_label = ($post_type === 'page') ? 'Page' : 'Post';
2535 $sync_status = ($post_status === 'publish') ? 'published' : $post_status;
2536
2537 metasync_log_sync_history([
2538 'title' => "{$content_type_label} Updated ({$title_preview})",
2539 'source' => 'Content Genius',
2540 'status' => $sync_status,
2541 'content_type' => ucfirst($post_type),
2542 'url' => $permalink ?? get_permalink($post_id),
2543 'meta_data' => json_encode([
2544 'post_id' => $post_id,
2545 'post_title' => $post_title,
2546 'post_type' => $post_type,
2547 'post_status' => $post_status,
2548 'action' => 'updated',
2549 'updated_fields' => array_keys($update_params)
2550 ])
2551 ]);
2552
2553 # Track Content Genius event in GA4
2554 try {
2555 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2556 } catch (Exception $e) {
2557 error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2558 }
2559 }
2560
2561 ksort($update_params);
2562 $data[] = $update_params;
2563 }
2564
2565 return rest_ensure_response($data);
2566 }
2567 /*
2568 Populate the style data into post meta or update the data
2569 */
2570 private function style_meta_data($styleData,$post_id,$update = false){
2571 // check if $styleData is an array
2572 if(is_array($styleData)){
2573 //loop through every key present in the $styleData
2574 foreach($styleData as $key=> $styleItem){
2575 // store the post meta on the basis of the key check if it comes from page_update or page_create function
2576 if($update){
2577 update_post_meta((int)$post_id, $key, json_encode($styleItem)); // update the style data
2578 }else{
2579 add_post_meta((int)$post_id, $key, json_encode($styleItem), true ); // store the style data
2580 }
2581
2582 }
2583 }
2584 }
2585
2586 public function create_page($request)
2587 {
2588 $payload = $request->get_json_params();
2589
2590 #check if we have the params set
2591 if (!isset($payload[0]) || empty($payload[0])) {
2592 # Return an error response for invalid request data
2593 return new WP_Error(
2594 'validation_error',
2595 'Invalid request data. Empty Payload Provided',
2596 array('status' => 400)
2597 );
2598 }
2599
2600 #set the payload
2601 $payload = $payload[0];
2602
2603 $payload['post_type'] = "page";
2604 $createPages = $this->create_item($payload); // creating page
2605
2606 if (is_wp_error($createPages)) {
2607 return $createPages;
2608 }
2609
2610 $post_ids = array();
2611
2612 if (is_array($createPages) !== true) {
2613 $createPages = $createPages->data;
2614 }
2615 foreach ($createPages as $item) {
2616 array_push($post_ids, $item['post_id']);
2617 }
2618
2619 $payloadIndex = 0;
2620 $pageTemplate = 'default';
2621 $isOttoAiPage = !empty($payload['otto_ai_page']) && filter_var($payload['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2622 foreach ($post_ids as $post_id) {
2623 /*
2624 check if the payload for style_data and otto_ai_page is set or not
2625 Also Check if the otto_ai_page is true or not if set true set Metasync Template for the page
2626 */
2627 if(isset($payload['style_data']) && $isOttoAiPage){
2628 // Change the page template from default to Metasync Template
2629 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2630 // store the style_date in a variable to ease the process
2631 $styleData = $payload['style_data'];
2632 // check if $styleData is an array
2633 if(is_array($styleData)){
2634 // add the post meta by calling the style_meta_data function
2635 $this->style_meta_data($styleData,$post_id);
2636 }
2637 // delete the elementor data so that it won't create problem in rendering
2638 delete_post_meta( $post_id, '_elementor_data' );
2639 delete_post_meta( $post_id, '_elementor_version' );
2640 delete_post_meta( $post_id, '_elementor_css' );
2641 delete_post_meta( $post_id, '_elementor_page_assets' );
2642 }
2643 if (
2644 isset($payload[$payloadIndex]['is_blank']) &&
2645 !empty($payload[$payloadIndex]['is_blank']) &&
2646 $payload[$payloadIndex]['is_blank'] != 'false'
2647 ) {
2648 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
2649 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2650 }
2651 if($isOttoAiPage){
2652 // Change the page template from default to Metasync Template
2653 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2654 }
2655 if ($pageTemplate !== 'default') {
2656 update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2657 } else {
2658 // Clear stale templates that conflict with the active page builder.
2659 // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2660 // written by the HTML-to-builder converter on an Oxygen site.
2661 $current = get_post_meta($post_id, '_wp_page_template', true);
2662 $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2663 if (in_array($current, $stale_templates, true)) {
2664 delete_post_meta($post_id, '_wp_page_template');
2665 }
2666 }
2667 }
2668
2669 return rest_ensure_response($createPages);
2670 }
2671
2672 public function update_page($request)
2673 {
2674 $payload = $request->get_json_params();
2675
2676 if (!isset($payload[0]) || empty($payload[0])) {
2677 return new WP_Error(
2678 'validation_error',
2679 'Invalid request data. Empty Payload Provided',
2680 array('status' => 400)
2681 );
2682 }
2683
2684 $payload = $payload[0];
2685 $payload['post_type'] = "page";
2686
2687 $post_data = get_post($payload['post_id']);
2688 if(!isset($post_data->post_type)){
2689 return new WP_Error(
2690 'rest_page_type_fail',
2691 esc_html__('No page found in the database with requested ID.'),
2692 array('status' => 400)
2693 );
2694 }
2695 if ($post_data->post_type !== 'page') {
2696 // Verify if the post exists
2697 if ($post_data) {
2698 // Update the post type
2699 $post_data->post_type = 'page';
2700 // Save the changes
2701 wp_update_post($post_data);
2702 }
2703 }
2704
2705 $updatePages = $this->update_items($payload); // updating page
2706
2707 if (is_wp_error($updatePages)) {
2708 return $updatePages;
2709 }
2710
2711 $post_ids = array();
2712 foreach ($updatePages->data as $item) {
2713 array_push($post_ids, $item['post_id']);
2714 }
2715
2716 $payloadIndex = 0;
2717 $pageTemplate = 'default';
2718 $isOttoAiPage = !empty($payload['otto_ai_page']) && filter_var($payload['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2719 foreach ($post_ids as $post_id) {
2720 /*
2721 check if the payload for style_data and otto_ai_page is set or not
2722 Also Check if the otto_ai_page is true or not if set true
2723 Update the Metasync Template for the page with css and js
2724 */
2725 if(isset($payload['style_data']) && $isOttoAiPage){
2726 // Change the page template from default to Metasync Template
2727 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2728 // store the style_date in a variable to ease the process
2729 $styleData = $payload['style_data'];
2730 // check if $styleData is an array
2731 if(is_array($styleData)){
2732 // update the post meta by calling the style_meta_data function
2733 $this->style_meta_data($styleData,$post_id,true);
2734 }
2735 }
2736 if (
2737 isset($payload[$payloadIndex]['is_blank']) &&
2738 !empty($payload[$payloadIndex]['is_blank']) &&
2739 $payload[$payloadIndex]['is_blank'] != 'false'
2740 ) {
2741 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
2742 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2743 }
2744 if($isOttoAiPage){
2745 // Change the page template from default to Metasync Template
2746 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2747 }
2748 if ($pageTemplate !== 'default') {
2749 update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2750 } else {
2751 // Clear stale templates that conflict with the active page builder.
2752 $current = get_post_meta($post_id, '_wp_page_template', true);
2753 $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2754 if (in_array($current, $stale_templates, true)) {
2755 delete_post_meta($post_id, '_wp_page_template');
2756 }
2757 }
2758 }
2759 return rest_ensure_response($updatePages->data);
2760 }
2761
2762 public function delete_page()
2763 {
2764 $deletePage = $this->delete_item(); // deleting page
2765 return rest_ensure_response($deletePage);
2766 }
2767
2768 /**
2769 * Data or Response received from HeartBeat API for admin area.
2770 */
2771 public function lgSendCustomerPostParams()
2772 {
2773 $sync_request = new Metasync_Sync_Requests();
2774 $response = $sync_request->SyncCustomerParams();
2775
2776 $responseCode = wp_remote_retrieve_response_code($response);
2777 if ($responseCode == 200) {
2778 $dt = new DateTime();
2779 $send_auth_token_timestamp = Metasync::get_option();
2780 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = $dt->format('M d, Y h:i:s A');
2781 Metasync::set_option($send_auth_token_timestamp);
2782 }
2783 }
2784
2785 public function linkgraph_login()
2786 {
2787 $post_data = metasync_sanitize_input_array($_POST);
2788 $payload = array(
2789 'username' => wp_unslash(sanitize_email($post_data['username'])),
2790 'password' => wp_unslash(sanitize_text_field($post_data['password']))
2791 );
2792
2793 $api_domain = class_exists('Metasync_Endpoint_Manager')
2794 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
2795 : Metasync::API_DOMAIN;
2796
2797 # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests
2798 $response = wp_remote_post($api_domain . '/api/token/', array(
2799 'body' => $payload,
2800 'timeout' => 10,
2801 ));
2802
2803 # Error handling for timeout or connection failures
2804 if (is_wp_error($response)) {
2805 error_log('MetaSync: Token API failed: ' . $response->get_error_message());
2806 wp_send_json_error(array('message' => 'Token API request failed'));
2807 wp_die();
2808 }
2809
2810 $get_object = isset($response['body']) ? json_decode($response['body']) : array();
2811 if (!empty($get_object)) {
2812 wp_send_json($get_object);
2813 }
2814 wp_die();
2815 }
2816
2817 public function sync_heartbeat_data()
2818 {
2819 $sync_heartbeat_data = new Metasync_Sync_Requests();
2820 $response = $sync_heartbeat_data->SyncCustomerParams();
2821
2822 $responseCode = wp_remote_retrieve_response_code($response);
2823 if ($responseCode == 200) {
2824 return rest_ensure_response($response);
2825 }
2826 return rest_ensure_response($response);
2827 }
2828
2829 public function get_heartbeat_errorlogs()
2830 {
2831 $heartbeat_error_db = new Metasync_HeartBeat_Error_Monitor_Database();
2832 $response = $heartbeat_error_db->getAllRecords();
2833
2834 if (!empty($response)) {
2835 return rest_ensure_response($response);
2836 }
2837 return rest_ensure_response(['Error logs not found']);
2838 }
2839
2840 /**
2841 * Search Atlas Connect Callback Permission Validation
2842 *
2843 * Validates the nonce token before Search Atlas delivers the API key and Otto UUID.
2844 * Does NOT create a WordPress login session.
2845 * Validates the nonce token in the x-api-key header
2846 */
2847 public function validate_searchatlas_callback_permission($request)
2848 {
2849 try {
2850 // Step 1: Validate nonce token format from header
2851 $nonce_token = $request->get_header('x-api-key');
2852 $format_validation = $this->validate_searchatlas_nonce_format($nonce_token);
2853
2854 if (is_wp_error($format_validation)) {
2855 return $format_validation;
2856 }
2857
2858 // Step 2: Validate token exists and is not expired (READ-ONLY check)
2859 // BUGFIX: Don't call validate_deterministic_searchatlas_token() here because it marks token as used!
2860 // Just check if token exists and hasn't expired
2861 if (empty($nonce_token) || strlen($nonce_token) < 32) {
2862 return new WP_Error(
2863 'invalid_nonce_token',
2864 'Invalid nonce token format',
2865 array('status' => 401)
2866 );
2867 }
2868
2869 // Check token exists in transients (read-only - don't modify)
2870 $transient_key = get_transient('metasync_sa_connect_active_' . $nonce_token);
2871 if (empty($transient_key)) {
2872 return new WP_Error(
2873 'invalid_nonce_token',
2874 'Invalid or expired nonce token',
2875 array('status' => 401)
2876 );
2877 }
2878
2879 // Check token metadata exists (read-only)
2880 $token_metadata = get_transient($transient_key);
2881 if (empty($token_metadata) || !is_array($token_metadata)) {
2882 return new WP_Error(
2883 'invalid_nonce_token',
2884 'Invalid nonce token',
2885 array('status' => 401)
2886 );
2887 }
2888
2889 // Check not expired (read-only)
2890 if (isset($token_metadata['expires']) && time() > $token_metadata['expires']) {
2891 return new WP_Error(
2892 'invalid_nonce_token',
2893 'Nonce token expired',
2894 array('status' => 401)
2895 );
2896 }
2897
2898 // Permission granted - but don't mark token as used yet!
2899 // The main handler will do that after successful processing
2900 return true;
2901 } catch (Exception $e) {
2902 return new WP_Error(
2903 'permission_validation_error',
2904 'Internal error during permission validation',
2905 array('status' => 500)
2906 );
2907 }
2908 }
2909
2910 /**
2911 * Validate Search Atlas connect token for callback
2912 * SECURITY FIX (CVE-2025-14386): Only validates against time-limited transient tokens
2913 * Tokens must be created by generate_searchatlas_connect_url() and stored in transients
2914 */
2915 private function validate_deterministic_searchatlas_token($token)
2916 {
2917 if (empty($token) || strlen($token) < 32) {
2918 return false;
2919 }
2920
2921 // SECURITY FIX: Token MUST exist in transients (created by generate_searchatlas_connect_url)
2922 // We do NOT fall back to apikey - this was the vulnerability!
2923 $transient_key = get_transient('metasync_sa_connect_active_' . $token);
2924
2925 if (empty($transient_key)) {
2926 return false;
2927 }
2928
2929 // Token found in transients - validate metadata
2930 $token_metadata = get_transient($transient_key);
2931
2932 if (empty($token_metadata) || !is_array($token_metadata)) {
2933 delete_transient('metasync_sa_connect_active_' . $token);
2934 return false;
2935 }
2936
2937
2938 // Check expiration
2939 if (isset($token_metadata['expires']) && time() > $token_metadata['expires']) {
2940 delete_transient($transient_key);
2941 delete_transient('metasync_sa_connect_active_' . $token);
2942 return false;
2943 }
2944
2945 // Check if already used for callback (single-use enforcement)
2946 if (isset($token_metadata['callback_used']) && $token_metadata['callback_used'] === true) {
2947 return false;
2948 }
2949
2950 // Mark token as used for callback (single-use)
2951 $token_metadata['callback_used'] = true;
2952 $token_metadata['callback_at'] = time();
2953 // BUGFIX: Keep metadata for 5 minutes to allow user login to complete
2954 set_transient($transient_key, $token_metadata, 300); // Keep for 5 minutes for user login
2955
2956 // BUGFIX: Only delete the active token mapping if BOTH operations are complete
2957 // This allows user login and API callback to happen in any order without race conditions
2958 if (isset($token_metadata['used']) && $token_metadata['used'] === true) {
2959 // Both callback and login are done - safe to delete mapping
2960 delete_transient('metasync_sa_connect_active_' . $token);
2961 }
2962 // Otherwise, keep the mapping so user login can still find the token
2963
2964 return true;
2965 }
2966
2967 /**
2968 * Check if token is an enhanced SALT-based token
2969 */
2970 private function is_enhanced_token($token)
2971 {
2972 // Enhanced tokens are exactly 64 characters (SHA256 hash)
2973 // and include SALT-based entropy
2974 if (strlen($token) !== 64 || !ctype_xdigit($token)) {
2975 return false;
2976 }
2977
2978 // Check if token data indicates enhanced version
2979 $nonce_data = get_option('metasync_sa_connect_nonce_' . $token);
2980 if ($nonce_data) {
2981 $data = json_decode($nonce_data, true);
2982 return isset($data['enhanced']) && $data['enhanced'] === true;
2983 }
2984
2985 return false;
2986 }
2987
2988 /**
2989 * Validate enhanced SALT-based Search Atlas connect token
2990 */
2991 private function validate_enhanced_searchatlas_token($token)
2992 {
2993 $nonce_data = get_option('metasync_sa_connect_nonce_' . $token);
2994
2995 if (!$nonce_data) {
2996 return false;
2997 }
2998
2999 $nonce_data = json_decode($nonce_data, true);
3000
3001 if (!is_array($nonce_data)) {
3002 return false;
3003 }
3004
3005 // Check if token has expired
3006 if (isset($nonce_data['expires']) && $nonce_data['expires'] < time()) {
3007 delete_option('metasync_sa_connect_nonce_' . $token);
3008 return false;
3009 }
3010
3011 // Check if token has already been used
3012 if (isset($nonce_data['used']) && $nonce_data['used']) {
3013 return false;
3014 }
3015
3016 // Additional validation for enhanced tokens
3017 if (isset($nonce_data['enhanced']) && $nonce_data['enhanced']) {
3018 // Perform additional security checks for enhanced tokens
3019 if (!$this->validate_enhanced_token_security($token, $nonce_data)) {
3020 return false;
3021 }
3022 }
3023
3024 return $nonce_data;
3025 }
3026
3027 /**
3028 * Validate legacy Search Atlas connect token (backward compatibility)
3029 */
3030 private function validate_legacy_searchatlas_token($token)
3031 {
3032 $nonce_data = get_option('metasync_sa_connect_nonce_' . $token);
3033
3034 if (!$nonce_data) {
3035 return false;
3036 }
3037
3038 $nonce_data = json_decode($nonce_data, true);
3039
3040 // Check if token has expired
3041 if (isset($nonce_data['expires']) && $nonce_data['expires'] < time()) {
3042 delete_option('metasync_sa_connect_nonce_' . $token);
3043 return false;
3044 }
3045
3046 // Check if token has already been used
3047 if (isset($nonce_data['used']) && $nonce_data['used']) {
3048 return false;
3049 }
3050
3051 return $nonce_data;
3052 }
3053
3054 /**
3055 * Additional security validation for enhanced tokens
3056 */
3057 private function validate_enhanced_token_security($token, $nonce_data)
3058 {
3059 // Rate limiting check (optional)
3060 if ($this->is_token_rate_limited($token)) {
3061 return false;
3062 }
3063
3064 // Time-based validation (ensure token is not too old for creation time)
3065 if (isset($nonce_data['created'])) {
3066 $creation_time = $nonce_data['created'];
3067 $current_time = time();
3068
3069 // Token shouldn't be older than 35 minutes (5 min buffer)
3070 if (($current_time - $creation_time) > 2100) {
3071 return false;
3072 }
3073 }
3074
3075 return true;
3076 }
3077
3078 /**
3079 * Simple rate limiting for token validation attempts
3080 */
3081 private function is_token_rate_limited($token)
3082 {
3083 $rate_limit_key = 'sa_connect_rate_limit_' . substr($token, 0, 8);
3084 $attempts = get_transient($rate_limit_key);
3085
3086 if ($attempts === false) {
3087 set_transient($rate_limit_key, 1, 300); // 5 minutes
3088 return false;
3089 }
3090
3091 if ($attempts >= 10) { // Max 10 attempts per 5 minutes
3092 return true;
3093 }
3094
3095 set_transient($rate_limit_key, $attempts + 1, 300);
3096 return false;
3097 }
3098
3099
3100 /**
3101 * Handle Search Atlas Connect Callback (REST API)
3102 *
3103 * Called by the Search Atlas platform after admin authenticates on their dashboard.
3104 * Receives the Search Atlas API key and Otto UUID and stores them in WordPress options.
3105 * Does NOT create a WordPress login session.
3106 * Processes the callback from Search Atlas platform with new API key
3107 */
3108 public function handle_searchatlas_api_callback($request)
3109 {
3110 try {
3111 // Step 1: Validate nonce token from header
3112 $nonce_token = $request->get_header('x-api-key');
3113 $nonce_validation = $this->validate_searchatlas_nonce_format($nonce_token);
3114
3115 if (is_wp_error($nonce_validation)) {
3116 return $nonce_validation;
3117 }
3118
3119 // Step 2: Validate request body structure
3120 $body_params = $request->get_json_params();
3121 $body_validation = $this->validate_searchatlas_request_body($body_params);
3122
3123 if (is_wp_error($body_validation)) {
3124 return $body_validation;
3125 }
3126
3127 // Step 3: Extract and validate individual parameters
3128 $validated_params = $this->extract_and_validate_searchatlas_params($body_params);
3129
3130 if (is_wp_error($validated_params)) {
3131 return $validated_params;
3132 }
3133
3134 // Step 4: Validate nonce token by regenerating it
3135 if (!$this->validate_deterministic_searchatlas_token($nonce_token)) {
3136 return new WP_Error(
3137 'invalid_nonce',
3138 'Invalid nonce token',
3139 array('status' => 401)
3140 );
3141 }
3142
3143 // Step 5: Process the callback and update settings
3144 $success = $this->mark_searchatlas_nonce_used(
3145 $nonce_token,
3146 $validated_params['api_key'],
3147 $validated_params['uuid'],
3148 $validated_params['status_code'],
3149 $validated_params['is_whitelabel'],
3150 $validated_params['whitelabel_domain'],
3151 $validated_params['whitelabel_logo'],
3152 $validated_params['whitelabel_company_name'],
3153 $validated_params['whitelabel_otto']
3154 );
3155
3156 if (!$success) {
3157 return new WP_Error(
3158 'update_failed',
3159 'Failed to update plugin settings',
3160 array('status' => 500)
3161 );
3162 }
3163
3164 // Step 6: Return success response
3165 return rest_ensure_response(array(
3166 'success' => true,
3167 'message' => 'Search Atlas connect callback processed successfully',
3168 'data' => array(
3169 'status_code' => $validated_params['status_code'],
3170 'api_key_updated' => $validated_params['status_code'] === 200,
3171 'whitelabel_enabled' => $validated_params['is_whitelabel'],
3172 'effective_domain' => Metasync::get_dashboard_domain()
3173 )
3174 ));
3175
3176 } catch (Exception $e) {
3177 return new WP_Error(
3178 'internal_error',
3179 'Internal server error occurred while processing Search Atlas connect callback',
3180 array('status' => 500)
3181 );
3182 }
3183 }
3184
3185 /**
3186 * Validate Search Atlas connect nonce token format
3187 *
3188 * @param string $nonce_token The nonce token to validate
3189 * @return true|WP_Error True if valid, WP_Error if invalid
3190 */
3191 private function validate_searchatlas_nonce_format($nonce_token)
3192 {
3193 // Check if nonce token is provided
3194 if (empty($nonce_token)) {
3195 return new WP_Error(
3196 'missing_nonce_token',
3197 'Missing x-api-key header with nonce token',
3198 array('status' => 401, 'field' => 'x-api-key')
3199 );
3200 }
3201
3202 // Check nonce token format (should be Plugin Auth Token - at least 8 characters)
3203 if (strlen($nonce_token) < 8) {
3204 return new WP_Error(
3205 'invalid_nonce_format',
3206 'Invalid nonce token format. Token too short',
3207 array('status' => 400, 'field' => 'x-api-key')
3208 );
3209 }
3210
3211 return true;
3212 }
3213
3214 /**
3215 * Validate Search Atlas connect request body structure
3216 *
3217 * @param mixed $body_params Request body parameters
3218 * @return true|WP_Error True if valid, WP_Error if invalid
3219 */
3220 private function validate_searchatlas_request_body($body_params)
3221 {
3222 // Check if body exists and is valid JSON
3223 if (empty($body_params)) {
3224 return new WP_Error(
3225 'empty_request_body',
3226 'Request body is empty or invalid JSON',
3227 array('status' => 400)
3228 );
3229 }
3230
3231 // Check if body is an array (parsed JSON object)
3232 if (!is_array($body_params)) {
3233 return new WP_Error(
3234 'invalid_request_body',
3235 'Request body must be a valid JSON object',
3236 array('status' => 400)
3237 );
3238 }
3239
3240 return true;
3241 }
3242
3243 /**
3244 * Extract and validate individual Search Atlas connect parameters
3245 *
3246 * @param array $body_params Request body parameters
3247 * @return array|WP_Error Validated parameters array or WP_Error
3248 */
3249 private function extract_and_validate_searchatlas_params($body_params)
3250 {
3251 $validation_errors = array();
3252
3253 // Extract parameters
3254 $api_key = isset($body_params['api_key']) ? trim($body_params['api_key']) : '';
3255 $uuid = isset($body_params['uuid']) ? trim($body_params['uuid']) : '';
3256 $status_code = isset($body_params['status_code']) ? $body_params['status_code'] : 200;
3257
3258 // Validate api_key
3259 if (empty($api_key)) {
3260 $validation_errors['api_key'] = 'API key is required';
3261 } elseif (!is_string($api_key)) {
3262 $validation_errors['api_key'] = 'API key must be a string';
3263 } elseif (strlen($api_key) < 10) {
3264 $validation_errors['api_key'] = 'API key must be at least 10 characters long';
3265 } elseif (strlen($api_key) > 255) {
3266 $validation_errors['api_key'] = 'API key must not exceed 255 characters';
3267 } elseif (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $api_key)) {
3268 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3269 }
3270
3271 // Validate uuid
3272 if (empty($uuid)) {
3273 $validation_errors['uuid'] = 'UUID is required';
3274 } elseif (!is_string($uuid)) {
3275 $validation_errors['uuid'] = 'UUID must be a string';
3276 } elseif (strlen($uuid) > 100) {
3277 $validation_errors['uuid'] = 'UUID must not exceed 100 characters';
3278 }
3279
3280 // Validate status_code
3281 if (!is_numeric($status_code)) {
3282 $validation_errors['status_code'] = 'Status code must be a number';
3283 } else {
3284 $status_code = intval($status_code);
3285 if ($status_code < 100 || $status_code >= 600) {
3286 $validation_errors['status_code'] = 'Status code must be between 100 and 599';
3287 }
3288 }
3289
3290 // Extract and validate whitelabel fields
3291 $is_whitelabel = isset($body_params['is_whitelabel']) ? $body_params['is_whitelabel'] : false;
3292
3293 // Validate is_whitelabel
3294 if (isset($body_params['is_whitelabel']) && !is_bool($body_params['is_whitelabel'])) {
3295 // Handle string representations of boolean
3296 if (is_string($body_params['is_whitelabel'])) {
3297 $whitelabel_string = strtolower($body_params['is_whitelabel']);
3298 if (in_array($whitelabel_string, ['true', '1', 'yes', 'on'])) {
3299 $is_whitelabel = true;
3300 } elseif (in_array($whitelabel_string, ['false', '0', 'no', 'off', ''])) {
3301 $is_whitelabel = false;
3302 } else {
3303 $validation_errors['is_whitelabel'] = 'is_whitelabel must be a boolean value (true/false)';
3304 }
3305 } else {
3306 $validation_errors['is_whitelabel'] = 'is_whitelabel must be a boolean value';
3307 }
3308 }
3309
3310 // BUSINESS RULE: If is_whitelabel is false/null, disregard all other whitelabel fields
3311 if (!$is_whitelabel) {
3312 // Force all whitelabel fields to empty when is_whitelabel is false
3313 $whitelabel_domain = '';
3314 $whitelabel_logo = '';
3315 $whitelabel_company_name = '';
3316 $whitelabel_otto = '';
3317 } else {
3318 // Only extract and validate whitelabel fields when is_whitelabel is true
3319 $whitelabel_domain = isset($body_params['whitelabel_domain']) ? trim($body_params['whitelabel_domain']) : '';
3320 $whitelabel_logo = isset($body_params['whitelabel_logo']) ? trim($body_params['whitelabel_logo']) : '';
3321 $whitelabel_company_name = isset($body_params['whitelabel_company_name']) ? trim($body_params['whitelabel_company_name']) : '';
3322 $whitelabel_otto = isset($body_params['whitelabel_otto']) ? trim($body_params['whitelabel_otto']) : '';
3323
3324 // Validate whitelabel_domain (optional but must be valid URL if provided)
3325 if (!empty($whitelabel_domain)) {
3326 if (!is_string($whitelabel_domain)) {
3327 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a string';
3328 } elseif (strlen($whitelabel_domain) > 255) {
3329 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3330 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3331 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3332 } elseif (!in_array(parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3333 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3334 }
3335 }
3336
3337 // Validate whitelabel_logo (permissive validation - invalid URLs won't fail POST)
3338 if (!empty($whitelabel_logo)) {
3339 // Basic validation - only fail POST for serious issues
3340 if (!is_string($whitelabel_logo)) {
3341 $validation_errors['whitelabel_logo'] = 'Whitelabel logo must be a string';
3342 } elseif (strlen($whitelabel_logo) > 1000) {
3343 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3344 } else {
3345 // If URL is invalid, we'll clear it later but not fail the POST
3346 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3347 !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3348 // Don't add to validation_errors - let POST succeed but clear the field
3349 }
3350 }
3351 }
3352
3353 // Validate whitelabel_company_name (maps to Plugin Name)
3354 if (!empty($whitelabel_company_name)) {
3355 if (!is_string($whitelabel_company_name)) {
3356 $validation_errors['whitelabel_company_name'] = 'Whitelabel company name must be a string';
3357 } elseif (strlen($whitelabel_company_name) > 100) {
3358 $validation_errors['whitelabel_company_name'] = 'Whitelabel company name must not exceed 100 characters';
3359 } elseif (!preg_match('/^[a-zA-Z0-9\s\-\.\&\(\)\,\'\"]+$/', $whitelabel_company_name)) {
3360 $validation_errors['whitelabel_company_name'] = 'Whitelabel company name contains invalid characters. Only letters, numbers, spaces, and common punctuation allowed';
3361 }
3362 }
3363 }
3364
3365 // Return validation errors if any
3366 if (!empty($validation_errors)) {
3367 return new WP_Error(
3368 'validation_failed',
3369 'Request validation failed',
3370 array(
3371 'status' => 422,
3372 'validation_errors' => $validation_errors
3373 )
3374 );
3375 }
3376
3377 // Return sanitized and validated parameters
3378 return array(
3379 'api_key' => sanitize_text_field($api_key),
3380 'uuid' => sanitize_text_field($uuid),
3381 'status_code' => $status_code,
3382 'is_whitelabel' => $is_whitelabel,
3383 'whitelabel_domain' => !empty($whitelabel_domain) ? esc_url_raw($whitelabel_domain) : '',
3384 // Only store logo if it's a valid URL, otherwise store empty string
3385 'whitelabel_logo' => (!empty($whitelabel_logo) && filter_var($whitelabel_logo, FILTER_VALIDATE_URL)) ? esc_url_raw($whitelabel_logo) : '',
3386 'whitelabel_company_name' => !empty($whitelabel_company_name) ? sanitize_text_field($whitelabel_company_name) : '',
3387 'whitelabel_otto' => !empty($whitelabel_otto) ? sanitize_text_field($whitelabel_otto) : ''
3388 );
3389 }
3390
3391 /**
3392 * Create standardized error response for Search Atlas connect endpoints
3393 *
3394 * @param string $error_code Error code identifier
3395 * @param string $message Human-readable error message
3396 * @param int $status_code HTTP status code
3397 * @param array $additional_data Additional error context
3398 * @return WP_Error Formatted error response
3399 */
3400 private function create_sso_error_response($error_code, $message, $status_code = 400, $additional_data = array())
3401 {
3402 $error_data = array_merge(array(
3403 'status' => $status_code,
3404 'timestamp' => current_time('mysql', true),
3405 'endpoint' => 'searchatlas/connect/callback'
3406 ), $additional_data);
3407
3408 return new WP_Error($error_code, $message, $error_data);
3409 }
3410
3411 /**
3412 * Mark Search Atlas connect nonce as used and store the API key and Otto UUID
3413 * Enhanced with whitelabel support including logo and company name
3414 */
3415 public function mark_searchatlas_nonce_used($token, $new_api_key, $new_otto_uuid, $status_code = 200, $is_whitelabel = false, $whitelabel_domain = '', $whitelabel_logo = '', $whitelabel_company_name = '', $whitelabel_otto = '')
3416 {
3417 try {
3418 // DEBUG: Log that callback processing started
3419
3420 // Validate token parameter
3421 if (empty($token)) {
3422 return false;
3423 }
3424
3425 // No need to validate stored token data - token is deterministic
3426 // Simply proceed with updating plugin settings
3427
3428 // Update plugin settings
3429 $options = Metasync::get_option();
3430
3431 if (!is_array($options)) {
3432 $options = array();
3433 }
3434
3435 if (!isset($options['general'])) {
3436 $options['general'] = array();
3437 }
3438 // Only update the API key in settings if status_code is 200 (success)
3439 if ($status_code === 200) {
3440 $options['general']['searchatlas_api_key'] = $new_api_key;
3441 $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
3442 // Note: OTTO SSR is always enabled by default, no need to set
3443
3444 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3445 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
3446
3447 // Update authentication timestamp for polling detection (legacy - keeping for compatibility)
3448 $options['general']['send_auth_token_timestamp'] = current_time('mysql');
3449
3450 // �
3451 NEW: Set nonce-specific success flag for polling detection
3452 // This ensures only the specific nonce that was authenticated reports success
3453 $success_key = 'metasync_sa_connect_success_' . md5($token);
3454 set_transient($success_key, true, 300); // 5 minutes expiry
3455
3456 // Clear JWT token cache when API key is updated to ensure fresh tokens
3457 $this->clear_jwt_token_cache();
3458
3459 } else {
3460 }
3461
3462 // Map whitelabel fields consistently (regardless of status_code)
3463 if ($is_whitelabel) {
3464 // whitelabel_company_name → Plugin Name (general plugin branding)
3465 if (!empty($whitelabel_company_name)) {
3466 $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
3467 }
3468
3469 // whitelabel_otto → OTTO Features naming (separate from plugin name)
3470 if (!empty($whitelabel_otto)) {
3471 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
3472 }
3473 } else {
3474 // Clear whitelabel fields when not whitelabel
3475 unset($options['general']['white_label_plugin_name']);
3476 unset($options['general']['whitelabel_otto_name']);
3477 }
3478
3479 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3480 if (!isset($options['whitelabel'])) {
3481 $options['whitelabel'] = array();
3482 }
3483
3484 $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3485 $options['whitelabel']['domain'] = $whitelabel_domain;
3486 $options['whitelabel']['logo'] = $whitelabel_logo;
3487 $options['whitelabel']['updated_at'] = time();
3488
3489 // Log whitelabel configuration
3490 if ($is_whitelabel) {
3491 $log_parts = array('Whitelabel mode enabled');
3492 if (!empty($whitelabel_domain)) {
3493 $log_parts[] = 'domain: ' . $whitelabel_domain;
3494 }
3495 if (!empty($whitelabel_logo)) {
3496 $log_parts[] = 'logo: ' . $whitelabel_logo;
3497 }
3498 if (!empty($whitelabel_company_name)) {
3499 $log_parts[] = 'company: ' . $whitelabel_company_name;
3500 }
3501 if (!empty($whitelabel_otto)) {
3502 $log_parts[] = 'otto: ' . $whitelabel_otto;
3503 }
3504
3505 }
3506
3507 $save_result = Metasync::set_option($options);
3508
3509 if (!$save_result) {
3510 error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options');
3511 } else {
3512 if ($status_code === 200) {
3513 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3514 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
3515 $cache_data = array(
3516 'status' => true,
3517 'timestamp' => time(),
3518 'cached_until' => time() + 300,
3519 'updated_by' => 'callback_success_optimistic',
3520 );
3521 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3522 update_option('metasync_last_known_connection_state', true);
3523 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3524 $this->trigger_immediate_heartbeat_after_sa_connect();
3525 }
3526 }
3527
3528 return true;
3529
3530 } catch (Exception $e) {
3531 error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage());
3532 return false;
3533 }
3534 }
3535
3536 /**
3537 * Clear cached JWT tokens
3538 * Useful when authentication is reset or API key changes
3539 */
3540 private function clear_jwt_token_cache()
3541 {
3542 global $wpdb;
3543
3544 // Clear all JWT token transients
3545 $deleted = $wpdb->query(
3546 $wpdb->prepare(
3547 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3548 '_transient_metasync_jwt_token_%'
3549 )
3550 );
3551
3552 // Also clear timeout transients
3553 $wpdb->query(
3554 $wpdb->prepare(
3555 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3556 '_transient_timeout_metasync_jwt_token_%'
3557 )
3558 );
3559
3560
3561 }
3562
3563 /**
3564 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
3565 * This provides immediate feedback to the user about connection status
3566 */
3567 private function trigger_immediate_heartbeat_after_sa_connect()
3568 {
3569 try {
3570 // Use WordPress action system to trigger immediate heartbeat check
3571 // This is more reliable than trying to access admin class directly
3572 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
3573
3574 // Also ensure heartbeat cron is scheduled now that we have an API key
3575 do_action('metasync_ensure_heartbeat_cron_scheduled');
3576
3577 } catch (Exception $e) {
3578 error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage());
3579 }
3580 }
3581
3582 public function get_item_schema()
3583 {
3584 if (isset($this->schema)) {
3585 // Since WordPress 5.3, the schema can be cached in the $schema property.
3586 return $this->schema;
3587 }
3588
3589 $this->schema = array(
3590 // This tells the spec of JSON Schema we are using which is draft 4.
3591 '$schema' => 'http://json-schema.org/draft-04/schema#',
3592 // The title property marks the identity of the resource.
3593 'title' => 'post',
3594 'type' => 'object',
3595 // In JSON Schema you can specify object properties in the properties attribute.
3596 'properties' => array(
3597 'id' => array(
3598 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
3599 'type' => 'integer',
3600 'context' => array('view', 'edit', 'embed'),
3601 'readonly' => true,
3602 ),
3603 'content' => array(
3604 'description' => esc_html__('The content for the object.', 'my-textdomain'),
3605 'type' => 'string',
3606 ),
3607 ),
3608 );
3609
3610 return $this->schema;
3611 }
3612
3613 // Callback function to retrieve pages tree
3614 public function get_pages_list($data) {
3615 $post_type = $data['post_type'];
3616
3617 // Fetch the top-level posts or pages
3618 $query = new WP_Query(array(
3619 'post_type' => $post_type,
3620 'post_status' => array('publish', 'draft'),
3621 'order' => 'ASC',
3622 'posts_per_page' => -1,
3623 ));
3624
3625 $posts_array = array();
3626
3627 // Build the array of posts
3628 while ($query->have_posts()) {
3629 $query->the_post();
3630 $posts_array[] = array(
3631 'id' => get_the_ID(),
3632 'title' => get_the_title(),
3633 'parent' => wp_get_post_parent_id(get_the_ID()),
3634 );
3635 }
3636
3637 // Reset post data
3638 wp_reset_postdata();
3639 return new WP_REST_Response($posts_array, 200);
3640 }
3641
3642 /*
3643 * Get post title and post feature image setting
3644 * Add a New key to return value on the basis of post type
3645 */
3646 public function append_content_if_missing_elements($post_type) {
3647
3648 # Run the MetaSyncHiddenPostManager folder
3649 # apply_filters('metasync_hidden_post_manager', '');
3650 # Get Latest Metasync Option
3651 $metasyncData = Metasync::get_option();
3652
3653 # Default value for post title setting
3654 $title_in_headings = true;
3655
3656 # Default value for post feature image setting
3657 $image_in_content = true;
3658
3659 # Check if the title setting is added in the setting
3660 if(isset($metasyncData['general']['title_in_headings'])){
3661
3662 # Change the default value from the setting
3663 $title_in_headings = $metasyncData['general']['title_in_headings'][$post_type];
3664
3665 }
3666
3667 # Check if the post feature setting is added in the setting
3668 if (isset($metasyncData['general']['image_in_content'])) {
3669
3670 # Change the default value from the setting
3671 $image_in_content = $metasyncData['general']['image_in_content'][$post_type];;
3672
3673 }
3674 # Return the array of setting
3675 return array(
3676 'title_in_headings'=>$title_in_headings,
3677 'image_in_content'=>$image_in_content
3678 );
3679
3680
3681 }
3682
3683 /**
3684 * Create key file endpoint for Bing Webmaster Tools. It's called by OTTO/UCMS.
3685 * Creates a .txt file in WordPress root with the provided key as filename and content
3686 *
3687 * @param WP_REST_Request $request The REST request object
3688 * @return WP_REST_Response|WP_Error Response object
3689 */
3690 public function create_key_file($request) {
3691 # Get the JSON data from the request
3692 $data = $request->get_json_params();
3693
3694 # Try alternative parameter methods
3695 $body_params = $request->get_body_params();
3696 $key_param = $request->get_param('key');
3697 $post_key = $_POST['key'] ?? null;
3698 $request_key = $_REQUEST['key'] ?? null;
3699 $get_key = $_GET['key'] ?? null;
3700
3701 # Try to get key from multiple sources
3702 $key_value = null;
3703
3704 # Try JSON first
3705 if (!empty($data['key'])) {
3706 $key_value = $data['key'];
3707 }
3708 # Try body params (form data)
3709 elseif (!empty($body_params['key'])) {
3710 $key_value = $body_params['key'];
3711 }
3712 # Try direct parameter
3713 elseif (!empty($key_param)) {
3714 $key_value = $key_param;
3715 }
3716 # Try $_POST (for multipart/form-data)
3717 elseif (!empty($post_key)) {
3718 $key_value = $post_key;
3719 }
3720 # Try $_REQUEST (fallback)
3721 elseif (!empty($request_key)) {
3722 $key_value = $request_key;
3723 }
3724 # Try $_GET (query parameters)
3725 elseif (!empty($get_key)) {
3726 $key_value = $get_key;
3727 }
3728
3729 # Validate that key is provided
3730 if (empty($key_value)) {
3731 return rest_ensure_response(array(
3732 'error' => 'Key parameter is required',
3733 'code' => 'missing_key'
3734 ), 400);
3735 }
3736
3737 # Use the found key value
3738 $data['key'] = $key_value;
3739
3740 # Sanitize the key to ensure it's safe for filename
3741 $key = sanitize_file_name($data['key']);
3742
3743 # Validate key is not empty after sanitization
3744 if (empty($key)) {
3745 return rest_ensure_response(array(
3746 'error' => 'Invalid key provided',
3747 'code' => 'invalid_key'
3748 ), 400);
3749 }
3750
3751 # Get WordPress root directory
3752 $wp_root = ABSPATH;
3753
3754 # Construct the file path
3755 $file_path = $wp_root . $key . '.txt';
3756
3757 # Check if file already exists
3758 if (file_exists($file_path)) {
3759 return rest_ensure_response(array(
3760 'error' => 'File already exists',
3761 'code' => 'file_exists',
3762 'file_path' => $file_path
3763 ), 409);
3764 }
3765
3766 # Attempt to create the file
3767 $result = file_put_contents($file_path, $key);
3768
3769 # Check if file creation was successful
3770 if ($result === false) {
3771 return rest_ensure_response(array(
3772 'error' => 'Failed to create file',
3773 'code' => 'file_creation_failed',
3774 'file_path' => $file_path
3775 ), 500);
3776 }
3777
3778 # Return success response
3779 return rest_ensure_response(array(
3780 'success' => true,
3781 'message' => 'Key file created successfully',
3782 'file_path' => $file_path,
3783 'key' => $key,
3784 'file_size' => $result
3785 ), 200);
3786 }
3787
3788 }
3789