PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.7
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.7
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.7, at public/class-metasync-rest-api.php

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