PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.2
MxChat – AI Chatbot & Content Generation for WordPress v3.1.2
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / admin / class-knowledge-manager.php

class-knowledge-manager.php in MxChat – AI Chatbot & Content Generation for WordPress 3.1.2, at admin/class-knowledge-manager.php

7,379 lines 283.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File: admin/class-knowledge-manager.php
4 *
5 * Handles all knowledge base content processing for MxChat
6 * Including PDF, sitemap, content processing, and WordPress post management
7 */
8 if (!defined('ABSPATH')) {
9 exit; // Exit if accessed directly
10 }
11
12 class MxChat_Knowledge_Manager {
13
14 private $options;
15
16 /**
17 * Constructor - Register hooks for content processing
18 */
19 public function __construct() {
20 $this->options = get_option('mxchat_options', array());
21 $this->mxchat_init_hooks();
22
23 $this->mxchat_init_role_hooks();
24 }
25
26 /**
27 * Initialize WordPress hooks for content processing
28 *
29 */
30 private function mxchat_init_hooks() {
31 // Admin post handlers for form submissions
32 add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
35 add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
36
37 // AJAX handlers for real-time processing and status updates
38 add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
39 add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
40 add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
41 add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
42 add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
43 add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
44 add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
45 add_action('wp_ajax_mxchat_delete_chunks_by_url', array($this, 'ajax_mxchat_delete_chunks_by_url'));
46 add_action('wp_ajax_mxchat_delete_wordpress_prompt', array($this, 'ajax_mxchat_delete_wordpress_prompt'));
47 add_action('wp_ajax_mxchat_bulk_delete_knowledge', array($this, 'ajax_mxchat_bulk_delete_knowledge'));
48 add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
49
50 // Queue-based processing AJAX handlers
51 add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
52 add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
53 add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
54 add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
55 add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
56 add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
57 add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
58 add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
59 add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
60
61 // Hook for content deletion
62 add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
63
64 // WordPress post management hooks
65 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
66 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
67 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
68 add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
69
70 // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
71 // Priority 20 to run after ACF's own save (which runs at priority 10)
72 add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
73
74 add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
75
76 // WooCommerce product hooks (if WooCommerce is active)
77 if (class_exists('WooCommerce')) {
78 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
79 add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
80 add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
81 add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
82 }
83 }
84
85 /**
86 * Get current options (refreshed)
87 */
88 private function mxchat_get_options() {
89 if (empty($this->options)) {
90 $this->options = get_option('mxchat_options', array());
91 }
92 return $this->options;
93 }
94
95
96 // ========================================
97 // MAIN CONTENT SUBMISSION HANDLERS
98 // ========================================
99
100 public function mxchat_handle_content_submission() {
101 // Check if the form was submitted and the user has permission.
102 if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
103 return;
104 }
105
106 // Verify the nonce.
107 $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
108 if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
109 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
110 }
111
112 // Sanitize the inputs.
113 // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
114 $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
115 $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
116
117 // Get bot_id from form submission
118 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
119
120 // Get bot-specific options and API key
121 $bot_options = $this->get_bot_options($bot_id);
122 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
123 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
124
125 if (strpos($selected_model, 'voyage') === 0) {
126 $api_key = $options['voyage_api_key'] ?? '';
127 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
128 $api_key = $options['gemini_api_key'] ?? '';
129 } else {
130 $api_key = $options['api_key'] ?? '';
131 }
132
133 if (empty($api_key)) {
134 set_transient('mxchat_admin_notice_error',
135 esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
136 30
137 );
138 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
139 exit;
140 }
141
142 // Use centralized utility function with bot_id
143 $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
144
145 if (is_wp_error($result)) {
146 set_transient('mxchat_admin_notice_error',
147 esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
148 30
149 );
150 } else {
151 set_transient('mxchat_admin_notice_success',
152 esc_html__('Content successfully submitted!', 'mxchat'),
153 30
154 );
155 }
156
157 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
158 exit;
159 }
160
161 public function mxchat_is_pdf_url($url, $response) {
162 $content_type = wp_remote_retrieve_header($response, 'content-type');
163 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
164
165 return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf';
166 }
167
168
169 public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
170 if (!current_user_can('manage_options')) {
171 return false;
172 }
173
174 $pdf_url = esc_url_raw($pdf_url);
175 $upload_dir = wp_upload_dir();
176
177 if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
178 return false;
179 }
180
181 $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
182 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
183
184 $response_body = wp_remote_retrieve_body($response);
185 if (empty($response_body)) {
186 return false;
187 }
188
189 if (!wp_mkdir_p(dirname($pdf_path))) {
190 return false;
191 }
192
193 try {
194 file_put_contents($pdf_path, $response_body);
195
196 if (!file_exists($pdf_path)) {
197 throw new Exception(__('Failed to save PDF file', 'mxchat'));
198 }
199
200 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
201
202 if ($total_pages === false || $total_pages < 1) {
203 throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
204 }
205
206 // Create unique queue ID
207 $queue_id = 'pdf_' . md5($pdf_url . time());
208
209 // Create array of pages to process
210 $pages = array();
211 for ($i = 1; $i <= $total_pages; $i++) {
212 $pages[] = array(
213 'pdf_path' => $pdf_path,
214 'pdf_url' => $pdf_url,
215 'page_number' => $i,
216 'total_pages' => $total_pages
217 );
218 }
219
220 // Add pages to queue
221 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
222
223 if ($queued_count === 0) {
224 wp_delete_file($pdf_path);
225 throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
226 }
227
228 // Store queue metadata
229 $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
230 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
231 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
232 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
233 $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
234 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
235
236 // Store queue ID in transient for status tracking
237 set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
238 set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
239
240 return 'queued';
241
242 } catch (Exception $e) {
243 if (file_exists($pdf_path)) {
244 wp_delete_file($pdf_path);
245 }
246 return $e->getMessage();
247 }
248 }
249
250 /**
251 * Handle direct PDF file upload from the knowledge base page
252 */
253 public function mxchat_handle_pdf_file_submission() {
254 if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
255 wp_die(esc_html__('Unauthorized access', 'mxchat'));
256 }
257
258 check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
259
260 $redirect_url = admin_url('admin.php?page=mxchat-prompts');
261
262 // Validate file upload
263 if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
264 $error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
265 $error_messages = array(
266 UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
267 UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
268 UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
269 UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
270 UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
271 UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
272 );
273 $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
274 set_transient('mxchat_admin_notice_error', $error_msg, 30);
275 wp_safe_redirect(esc_url($redirect_url));
276 exit;
277 }
278
279 $file = $_FILES['pdf_file'];
280
281 // Validate MIME type
282 $finfo = finfo_open(FILEINFO_MIME_TYPE);
283 $mime_type = finfo_file($finfo, $file['tmp_name']);
284 finfo_close($finfo);
285
286 if ($mime_type !== 'application/pdf') {
287 set_transient('mxchat_admin_notice_error',
288 esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
289 30
290 );
291 wp_safe_redirect(esc_url($redirect_url));
292 exit;
293 }
294
295 // Validate extension
296 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
297 if ($ext !== 'pdf') {
298 set_transient('mxchat_admin_notice_error',
299 esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
300 30
301 );
302 wp_safe_redirect(esc_url($redirect_url));
303 exit;
304 }
305
306 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
307 $original_filename = sanitize_file_name($file['name']);
308
309 $upload_dir = wp_upload_dir();
310 if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
311 set_transient('mxchat_admin_notice_error',
312 esc_html__('WordPress upload directory is not writable.', 'mxchat'),
313 30
314 );
315 wp_safe_redirect(esc_url($redirect_url));
316 exit;
317 }
318
319 $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
320 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
321
322 if (!wp_mkdir_p(dirname($pdf_path))) {
323 set_transient('mxchat_admin_notice_error',
324 esc_html__('Failed to create upload directory.', 'mxchat'),
325 30
326 );
327 wp_safe_redirect(esc_url($redirect_url));
328 exit;
329 }
330
331 // Move uploaded file
332 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
333 set_transient('mxchat_admin_notice_error',
334 esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
335 30
336 );
337 wp_safe_redirect(esc_url($redirect_url));
338 exit;
339 }
340
341 try {
342 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
343
344 if ($total_pages === false || $total_pages < 1) {
345 throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
346 }
347
348 // Use original filename as the source identifier
349 $source_label = 'upload://' . $original_filename;
350
351 $queue_id = 'pdf_' . md5($source_label . time());
352
353 $pages = array();
354 for ($i = 1; $i <= $total_pages; $i++) {
355 $pages[] = array(
356 'pdf_path' => $pdf_path,
357 'pdf_url' => $source_label,
358 'page_number' => $i,
359 'total_pages' => $total_pages,
360 );
361 }
362
363 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
364
365 if ($queued_count === 0) {
366 wp_delete_file($pdf_path);
367 throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
368 }
369
370 $this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
371 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
372 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
373 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
374 $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
375 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
376
377 set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
378 set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
379
380 set_transient('mxchat_admin_notice_success',
381 sprintf(
382 esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
383 esc_html($original_filename),
384 $total_pages
385 ),
386 30
387 );
388
389 } catch (Exception $e) {
390 if (file_exists($pdf_path)) {
391 wp_delete_file($pdf_path);
392 }
393 set_transient('mxchat_admin_notice_error',
394 esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
395 30
396 );
397 }
398
399 wp_safe_redirect(esc_url($redirect_url));
400 exit;
401 }
402
403 /**
404 * Validate PDF and count pages with multiple parser attempts
405 */
406 private function mxchat_validate_and_count_pdf_pages($pdf_path) {
407 // Method 1: Try with Smalot PDF Parser (your current method)
408 try {
409 mxchat_load_pdf_parser();
410 $parser = new \Smalot\PdfParser\Parser();
411 $pdf = $parser->parseFile($pdf_path);
412 $pages = $pdf->getPages();
413 $page_count = count($pages);
414
415 if ($page_count > 0) {
416 //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
417 return $page_count;
418 }
419 } catch (Exception $e) {
420 //error_log('Smalot PDF parser failed: ' . $e->getMessage());
421 }
422
423 // Method 2: Try with pdfinfo command (if available)
424 if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
425 try {
426 $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
427 $output = shell_exec($command);
428
429 if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
430 $page_count = intval($matches[1]);
431 if ($page_count > 0) {
432 //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
433 return $page_count;
434 }
435 }
436 } catch (Exception $e) {
437 //error_log('pdfinfo command failed: ' . $e->getMessage());
438 }
439 }
440
441 // Method 3: Try to repair PDF and parse again
442 try {
443 $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
444 if ($repaired_path && $repaired_path !== $pdf_path) {
445 mxchat_load_pdf_parser();
446 $parser = new \Smalot\PdfParser\Parser();
447 $pdf = $parser->parseFile($repaired_path);
448 $pages = $pdf->getPages();
449 $page_count = count($pages);
450
451 if ($page_count > 0) {
452 // Replace original with repaired version
453 copy($repaired_path, $pdf_path);
454 unlink($repaired_path);
455 //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
456 return $page_count;
457 }
458
459 // Clean up repaired file if it didn't work
460 unlink($repaired_path);
461 }
462 } catch (Exception $e) {
463 //error_log('PDF repair attempt failed: ' . $e->getMessage());
464 }
465
466 // Method 4: Manual PDF structure analysis (basic page count)
467 try {
468 $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
469 if ($page_count > 0) {
470 //error_log('PDF page count determined manually: ' . $page_count . ' pages');
471 return $page_count;
472 }
473 } catch (Exception $e) {
474 //error_log('Manual PDF analysis failed: ' . $e->getMessage());
475 }
476
477 //error_log('All PDF parsing methods failed for: ' . $pdf_path);
478 return false;
479 }
480
481 /**
482 * Check if shell_exec is disabled
483 */
484 private function mxchat_is_shell_disabled() {
485 $disabled = explode(',', ini_get('disable_functions'));
486 return in_array('shell_exec', $disabled);
487 }
488
489 /**
490 * Attempt to repair PDF using basic methods
491 */
492 private function mxchat_attempt_pdf_repair($pdf_path) {
493 try {
494 $content = file_get_contents($pdf_path);
495 if (!$content) {
496 return false;
497 }
498
499 // Check if PDF starts with proper header
500 if (substr($content, 0, 4) !== '%PDF') {
501 // Try to find PDF header in the content
502 $header_pos = strpos($content, '%PDF');
503 if ($header_pos !== false && $header_pos < 1024) {
504 // Remove junk before PDF header
505 $content = substr($content, $header_pos);
506 $repaired_path = $pdf_path . '.repaired';
507 file_put_contents($repaired_path, $content);
508 return $repaired_path;
509 }
510 }
511
512 // Check for EOF marker
513 $content = rtrim($content);
514 if (!preg_match('/%%EOF\s*$/', $content)) {
515 // Add EOF marker if missing
516 $content .= "\n%%EOF";
517 $repaired_path = $pdf_path . '.repaired';
518 file_put_contents($repaired_path, $content);
519 return $repaired_path;
520 }
521
522 } catch (Exception $e) {
523 //error_log('PDF repair error: ' . $e->getMessage());
524 }
525
526 return false;
527 }
528
529 /**
530 * Manual PDF page counting by analyzing PDF structure
531 */
532 private function mxchat_manual_pdf_page_count($pdf_path) {
533 try {
534 $content = file_get_contents($pdf_path);
535 if (!$content) {
536 return 0;
537 }
538
539 // Method 1: Count /Type /Page objects
540 $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
541 if ($page_count > 0) {
542 return $page_count;
543 }
544
545 // Method 2: Look for /Count in pages object
546 if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
547 return intval($matches[1]);
548 }
549
550 // Method 3: Count page references
551 $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
552 if ($page_count > 0) {
553 return $page_count;
554 }
555
556 } catch (Exception $e) {
557 //error_log('Manual PDF analysis error: ' . $e->getMessage());
558 }
559
560 return 0;
561 }
562
563
564 public function mxchat_save_inline_prompt() {
565 // DEBUG: Log what we're receiving
566 //error_log('=== MXCHAT DEBUG ===');
567 //error_log('POST data: ' . print_r($_POST, true));
568 //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
569
570 // Check for nonce security
571 check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
572
573 // If we get here, nonce passed
574 //error_log('Nonce verification PASSED');
575
576 // Verify permissions
577 if (!current_user_can('manage_options')) {
578 wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
579 return;
580 }
581
582 global $wpdb;
583 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
584
585 // Validate and sanitize input data
586 $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
587 $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
588 $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
589
590 if ($prompt_id > 0 && !empty($article_content)) {
591 // Re-generate the embedding vector for the updated content
592 $embedding_vector = $this->mxchat_generate_embedding($article_content);
593 if (is_array($embedding_vector)) {
594 // Serialize the embedding vector before storing it
595 $embedding_vector_serialized = serialize($embedding_vector);
596 // Update the prompt in the database
597 $updated = $wpdb->update(
598 $table_name,
599 array(
600 'article_content' => $article_content,
601 'embedding_vector' => $embedding_vector_serialized,
602 'source_url' => $article_url,
603 ),
604 array('id' => $prompt_id),
605 array('%s', '%s', '%s'),
606 array('%d')
607 );
608 if ($updated !== false) {
609 wp_send_json_success();
610 } else {
611 MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
612 wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
613 }
614 } else {
615 MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
616 wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
617 }
618 } else {
619 wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
620 }
621 }
622
623
624 public function mxchat_get_pdf_processing_status($pdf_url) {
625 $pdf_url = esc_url_raw($pdf_url);
626 $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
627
628 if (!$status || !is_array($status)) {
629 return false;
630 }
631
632 // Check for stalled processing (no updates for 5 minutes)
633 if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
634 $status['status'] = 'error';
635 $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
636
637 // Save the updated status
638 set_transient(
639 sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
640 array_map('sanitize_text_field', $status),
641 DAY_IN_SECONDS
642 );
643 }
644
645 $result = array(
646 'total_pages' => absint($status['total_pages']),
647 'processed_pages' => absint($status['processed_pages']),
648 'failed_pages' => absint($status['failed_pages'] ?? 0),
649 'percentage' => ($status['total_pages'] > 0)
650 ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
651 : 0,
652 'status' => sanitize_text_field($status['status']),
653 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
654 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
655 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
656 );
657
658 // Add error message if present
659 if (isset($status['error']) && !empty($status['error'])) {
660 $result['error'] = sanitize_text_field($status['error']);
661 }
662
663 return $result;
664 }
665
666
667 public function mxchat_handle_sitemap_submission() {
668 // Check if the form was submitted and verify permissions
669 if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
670 wp_die(esc_html__('Unauthorized access', 'mxchat'));
671 }
672
673 // Verify nonce
674 check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
675
676 // Validate URL
677 if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
678 set_transient('mxchat_admin_notice_error',
679 esc_html__('Please provide a valid URL.', 'mxchat'),
680 30
681 );
682 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
683 exit;
684 }
685
686 $submitted_url = esc_url_raw($_POST['sitemap_url']);
687
688 // Get bot_id from form submission
689 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
690
691 // Get bot-specific options and validate API key
692 $bot_options = $this->get_bot_options($bot_id);
693 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
694 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
695
696 if (strpos($selected_model, 'voyage') === 0) {
697 $api_key = $options['voyage_api_key'] ?? '';
698 $provider_name = 'Voyage AI';
699 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
700 $api_key = $options['gemini_api_key'] ?? '';
701 $provider_name = 'Google Gemini';
702 } else {
703 $api_key = $options['api_key'] ?? '';
704 $provider_name = 'OpenAI';
705 }
706
707 if (empty($api_key)) {
708 $error_message = sprintf(
709 esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
710 $provider_name
711 );
712 set_transient('mxchat_admin_notice_error', $error_message, 30);
713 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
714 exit;
715 }
716
717 // Fetch URL
718 $response = wp_remote_get($submitted_url, array('timeout' => 30));
719
720 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
721 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
722 set_transient('mxchat_admin_notice_error',
723 sprintf(
724 esc_html__('Failed to fetch the URL: %s', 'mxchat'),
725 esc_html($error_message)
726 ),
727 30
728 );
729 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
730 exit;
731 }
732
733 $content_type = wp_remote_retrieve_header($response, 'content-type');
734 $body_content = wp_remote_retrieve_body($response);
735
736 if (empty($body_content)) {
737 set_transient('mxchat_admin_notice_error',
738 esc_html__('Empty response received from URL.', 'mxchat'),
739 30
740 );
741 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
742 exit;
743 }
744
745 // Handle PDF URL
746 if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
747 $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
748
749 if ($result === 'queued') {
750 set_transient('mxchat_admin_notice_success',
751 esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
752 30
753 );
754 } else {
755 set_transient('mxchat_admin_notice_error',
756 esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
757 30
758 );
759 }
760
761 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
762 exit;
763 }
764
765 // Handle Sitemap XML
766 if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
767 libxml_use_internal_errors(true);
768 $xml = simplexml_load_string($body_content);
769 $xml_errors = libxml_get_errors();
770 libxml_clear_errors();
771
772 if ($xml === false || !empty($xml_errors)) {
773 set_transient('mxchat_admin_notice_error',
774 esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
775 30
776 );
777 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
778 exit;
779 }
780
781 $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
782
783 if ($result === 'queued') {
784 set_transient('mxchat_admin_notice_success',
785 esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
786 30
787 );
788 } else {
789 set_transient('mxchat_admin_notice_error',
790 esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
791 30
792 );
793 }
794
795 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
796 exit;
797 }
798
799 // Handle Regular URL (single page)
800 $page_content = $this->mxchat_extract_main_content($body_content);
801 $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
802
803 error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
804 error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
805 error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
806 error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
807
808 if (empty($sanitized_content)) {
809 set_transient('mxchat_admin_notice_error',
810 esc_html__('No valid content found on the provided URL.', 'mxchat'),
811 30
812 );
813 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
814 exit;
815 }
816
817 // For single URLs, process immediately using submit_content_to_db
818 // This handles chunking automatically for large content
819 $db_result = MxChat_Utils::submit_content_to_db(
820 $sanitized_content,
821 $submitted_url,
822 $api_key,
823 null,
824 $bot_id,
825 'url' // content_type
826 );
827
828 if (is_wp_error($db_result)) {
829 $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
830 set_transient('mxchat_admin_notice_error', $error_message, 30);
831 } else {
832 $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
833 set_transient('mxchat_admin_notice_success', $success_message, 30);
834 }
835
836 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
837 exit;
838 }
839
840
841 public function mxchat_get_single_url_status() {
842 $status = get_transient('mxchat_single_url_status');
843 if (!$status) {
844 return null;
845 }
846
847 // Add human-readable time
848 if (isset($status['timestamp'])) {
849 $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
850 }
851
852 return $status;
853 }
854
855 public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
856 if (!current_user_can('manage_options')) {
857 return false;
858 }
859
860 try {
861 $sitemap_url = esc_url_raw($sitemap_url);
862
863 if (!$xml || !is_object($xml)) {
864 throw new Exception(__('Invalid XML object provided', 'mxchat'));
865 }
866
867 // Get bot-specific embedding API for validation
868 $bot_options = $this->get_bot_options($bot_id);
869 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
870
871 // Test the embedding API before processing
872 $test_phrase = "Test embedding generation for MxChat";
873 $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
874
875 if (is_string($test_result)) {
876 throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
877 }
878
879 if (!is_array($test_result)) {
880 throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
881 }
882
883 // Extract URLs from sitemap
884 $urls = array();
885 foreach ($xml->url as $url_element) {
886 $url = esc_url_raw((string)$url_element->loc);
887 if ($url) {
888 $urls[] = array('url' => $url);
889 }
890 }
891
892 $total_urls = count($urls);
893
894 if ($total_urls < 1) {
895 throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
896 }
897
898 // Create unique queue ID
899 $queue_id = 'sitemap_' . md5($sitemap_url . time());
900
901 // Add URLs to queue
902 $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
903
904 if ($queued_count === 0) {
905 throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
906 }
907
908 // Store queue metadata
909 $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
910 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
911 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
912 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
913 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
914
915 // Store queue ID in transient for status tracking
916 set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
917 set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
918
919 return 'queued';
920
921 } catch (Exception $e) {
922 $error_message = $e->getMessage();
923 error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
924
925 return $error_message;
926 }
927
928 }
929
930 /**
931 * Remove shortcode tags but preserve the content inside them
932 * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
933 *
934 * @param string $content The content containing shortcodes
935 * @return string Content with shortcode tags removed but inner content preserved
936 */
937 private function strip_shortcode_tags_preserve_content($content) {
938 // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
939 // Content between tags is inherently preserved since only brackets are targeted
940 $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
941 return ($result !== null) ? $result : $content;
942 }
943
944 public function mxchat_sanitize_content_for_api($content) {
945 //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
946
947 // Remove shortcode tags but PRESERVE content inside them
948 $content = $this->strip_shortcode_tags_preserve_content($content);
949
950 // Remove script, style tags, and HTML comments
951 $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
952 $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
953 $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
954
955 // Remove all HTML tags and decode HTML entities
956 $content = wp_strip_all_tags($content);
957 $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
958
959 // Normalize whitespace but preserve paragraph breaks
960 // First, normalize line endings to \n
961 $content = str_replace(["\r\n", "\r"], "\n", $content);
962 // Replace multiple spaces/tabs with single space, but preserve newlines
963 $content = preg_replace('/[ \t]+/', ' ', $content);
964 // Replace 3+ newlines with 2 newlines (max 2 blank lines)
965 $content = preg_replace('/\n{3,}/', "\n\n", $content);
966 // Trim each line
967 $lines = explode("\n", $content);
968 $lines = array_map('trim', $lines);
969 $content = implode("\n", $lines);
970 // Final trim
971 $content = trim($content);
972
973 // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
974 $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
975
976 // Remove NULL bytes which can cause database errors
977 $content = str_replace("\0", "", $content);
978
979 // Ensure valid UTF-8 encoding
980 $content = wp_check_invalid_utf8($content);
981
982 // Remove any extremely long strings without spaces (often garbage)
983 $content = preg_replace('/\S{300,}/', ' ', $content);
984
985 // Replace problematic characters that often cause database issues
986 $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
987
988 // Replace any remaining potentially problematic characters with spaces
989 // BUT preserve newlines by temporarily replacing them
990 $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
991 $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
992 $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
993
994 // Limit to reasonable length if needed
995 $max_length = 65000; // Just under MySQL TEXT field limit
996 if (strlen($content) > $max_length) {
997 $content = substr($content, 0, $max_length);
998 }
999
1000 //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
1001 return $content;
1002 }
1003 public function mxchat_extract_main_content($html) {
1004 if (empty($html)) {
1005 return '';
1006 }
1007 try {
1008 $dom = new DOMDocument;
1009 libxml_use_internal_errors(true); // Suppress HTML parsing errors
1010 @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
1011 $xpath = new DOMXPath($dom);
1012
1013 // For debugging purposes
1014 $debugEnabled = true; // Set to true to enable debugging output
1015 $debug = function($message) use ($debugEnabled) {
1016 if ($debugEnabled) {
1017 error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
1018 }
1019 };
1020
1021 // Direct targeting for Gerow theme posts
1022 $post_text = $xpath->query('//div[contains(@class, "post-text")]');
1023 if ($post_text && $post_text->length > 0) {
1024 $debug("Found post-text directly");
1025 $content = '';
1026 foreach ($post_text as $node) {
1027 $content .= $dom->saveHTML($node);
1028 }
1029 if (!empty($content)) {
1030 $debug("Returning post-text content");
1031 return $content;
1032 }
1033 }
1034
1035 // Try to get the blog details content which contains the post-text
1036 $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
1037 if ($blog_details && $blog_details->length > 0) {
1038 $debug("Found blog-details-content");
1039 $content = '';
1040 foreach ($blog_details as $node) {
1041 $content .= $dom->saveHTML($node);
1042 }
1043 if (!empty($content)) {
1044 $debug("Returning blog-details-content");
1045 return $content;
1046 }
1047 }
1048
1049 // Try to get the article which contains the blog details
1050 $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
1051 if ($article && $article->length > 0) {
1052 $debug("Found article with blog-details-wrap");
1053 $content = '';
1054 foreach ($article as $node) {
1055 $content .= $dom->saveHTML($node);
1056 }
1057 if (!empty($content)) {
1058 $debug("Returning article content");
1059 return $content;
1060 }
1061 }
1062
1063 // Try even broader with the blog-item-wrap
1064 $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
1065 if ($blog_item && $blog_item->length > 0) {
1066 $debug("Found blog-item-wrap");
1067 $content = '';
1068 foreach ($blog_item as $node) {
1069 $content .= $dom->saveHTML($node);
1070 }
1071 if (!empty($content)) {
1072 $debug("Returning blog-item-wrap content");
1073 return $content;
1074 }
1075 }
1076
1077 // Specific Gerow theme path
1078 $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
1079 if ($gerow_path && $gerow_path->length > 0) {
1080 $debug("Found Gerow theme path to post-text");
1081 $content = '';
1082 foreach ($gerow_path as $node) {
1083 $content .= $dom->saveHTML($node);
1084 }
1085 if (!empty($content)) {
1086 $debug("Returning Gerow post-text content");
1087 return $content;
1088 }
1089 }
1090
1091 // Generic blog post selectors
1092 $selectors = [
1093 // Blog post specific selectors
1094 '//div[contains(@class, "post-text")]',
1095 '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
1096 '//div[contains(@class, "blog-details-content")]',
1097 '//article[contains(@class, "blog-details-wrap")]',
1098 '//div[contains(@class, "entry-content")]',
1099 '//div[contains(@class, "blog-content")]',
1100 '//div[contains(@class, "blog-item-wrap")]',
1101
1102 // More general content selectors
1103 '//div[contains(@class, "page__content")]',
1104 '//div[contains(@class, "elementor-widget-container")]',
1105 '//div[contains(@class, "elementor-text-editor")]',
1106 '//div[contains(@class, "elementor-widget-text-editor")]',
1107 '//*[contains(@class, "entry-content")]',
1108 '//*[contains(@class, "post-content")]',
1109 '//*[contains(@class, "article-content")]',
1110 '//*[@id="content"]',
1111 '//*[@id="main-content"]',
1112 '//section[contains(@class, "blog-area")]',
1113 '//article',
1114 '//main',
1115 '//div[contains(@class, "content")]'
1116 ];
1117
1118 // First handle Elementor content - get only leaf widget containers to avoid duplicates
1119 $debug("Checking for Elementor content");
1120 // Get widget containers that are direct children of widgets (not nested inside other widget containers)
1121 $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
1122 if ($elementor_widgets && $elementor_widgets->length > 0) {
1123 $debug("Found Elementor widgets");
1124 $seen_content = array(); // Track seen content to avoid duplicates
1125 $combined_content = '';
1126 foreach ($elementor_widgets as $widget) {
1127 $widget_content = $dom->saveHTML($widget);
1128 if (!empty($widget_content)) {
1129 // Create a hash of the content to detect duplicates
1130 $content_hash = md5($widget_content);
1131 if (!isset($seen_content[$content_hash])) {
1132 $seen_content[$content_hash] = true;
1133 $combined_content .= $widget_content;
1134 }
1135 }
1136 }
1137 if (!empty($combined_content)) {
1138 $debug("Returning Elementor content");
1139 return $combined_content;
1140 }
1141 }
1142
1143 // Try standard selectors one by one
1144 foreach ($selectors as $selector) {
1145 $debug("Trying selector: " . $selector);
1146 $nodes = $xpath->query($selector);
1147 if ($nodes && $nodes->length > 0) {
1148 $debug("Found " . $nodes->length . " matches for selector: " . $selector);
1149 // Only take the FIRST matching node to avoid duplicate content
1150 // (pages often have nested or multiple containers with same class)
1151 $content = $dom->saveHTML($nodes->item(0));
1152 if (!empty($content)) {
1153 $debug("Returning content from selector: " . $selector . " (first match only)");
1154 return $content;
1155 }
1156 }
1157 }
1158
1159 // Manual regex fallback for post-text if DOM methods fail
1160 $debug("Trying regex fallback");
1161 if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
1162 $debug("Found post-text via regex");
1163 return '<div class="post-text">' . $matches[1] . '</div>';
1164 }
1165
1166 // Try to extract the blog section as a whole
1167 $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
1168 if ($blog_section && $blog_section->length > 0) {
1169 $debug("Found blog-area section");
1170 $content = '';
1171 foreach ($blog_section as $node) {
1172 $content .= $dom->saveHTML($node);
1173 }
1174 if (!empty($content)) {
1175 $debug("Returning blog-area section content");
1176 return $content;
1177 }
1178 }
1179
1180 // Generic container selectors for non-CMS sites (like .asp pages)
1181 $debug("Trying generic container selectors");
1182 $generic_selectors = [
1183 '//div[@id="main"]',
1184 '//div[@id="wrapper"]',
1185 '//div[@id="page"]',
1186 '//div[@id="site-content"]',
1187 '//div[contains(@class, "main-content")]',
1188 '//div[contains(@class, "page-content")]',
1189 '//div[contains(@class, "site-content")]',
1190 ];
1191
1192 foreach ($generic_selectors as $selector) {
1193 $debug("Trying generic selector: " . $selector);
1194 $nodes = $xpath->query($selector);
1195 if ($nodes && $nodes->length > 0) {
1196 $content = $dom->saveHTML($nodes->item(0));
1197 if (!empty($content)) {
1198 $debug("Returning content from generic selector: " . $selector);
1199 return $content;
1200 }
1201 }
1202 }
1203
1204 // Paragraph-based content detection - find regions with substantial text
1205 $debug("Trying paragraph-based content detection");
1206 $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
1207 if ($paragraphs && $paragraphs->length >= 3) {
1208 $debug("Found " . $paragraphs->length . " substantial paragraphs");
1209 // Collect all substantial paragraphs and their content
1210 $paragraph_content = '';
1211 foreach ($paragraphs as $p) {
1212 $paragraph_content .= $dom->saveHTML($p) . "\n";
1213 }
1214 if (!empty($paragraph_content)) {
1215 $debug("Returning paragraph-based content");
1216 return $paragraph_content;
1217 }
1218 }
1219
1220 // Improved body fallback - strip nav/header/footer elements first
1221 $debug("Using improved body fallback");
1222 $body = $dom->getElementsByTagName('body');
1223 if ($body->length > 0) {
1224 // Clone the body to avoid modifying the original DOM
1225 $body_clone = $body->item(0)->cloneNode(true);
1226
1227 // Remove common non-content elements by tag name
1228 $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
1229 foreach ($remove_tags as $tag) {
1230 $elements = $body_clone->getElementsByTagName($tag);
1231 // Iterate backwards to safely remove elements
1232 for ($i = $elements->length - 1; $i >= 0; $i--) {
1233 $el = $elements->item($i);
1234 if ($el && $el->parentNode) {
1235 $el->parentNode->removeChild($el);
1236 }
1237 }
1238 }
1239
1240 // Remove elements with common non-content class names using XPath on the cloned body
1241 $temp_dom = new DOMDocument();
1242 @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
1243 $temp_xpath = new DOMXPath($temp_dom);
1244
1245 $remove_class_patterns = [
1246 '//*[contains(@class, "nav")]',
1247 '//*[contains(@class, "menu")]',
1248 '//*[contains(@class, "sidebar")]',
1249 '//*[contains(@class, "footer")]',
1250 '//*[contains(@class, "header")]',
1251 '//*[contains(@id, "nav")]',
1252 '//*[contains(@id, "menu")]',
1253 '//*[contains(@id, "sidebar")]',
1254 '//*[contains(@id, "footer")]',
1255 '//*[contains(@id, "header")]',
1256 ];
1257
1258 foreach ($remove_class_patterns as $pattern) {
1259 $elements = $temp_xpath->query($pattern);
1260 if ($elements) {
1261 for ($i = $elements->length - 1; $i >= 0; $i--) {
1262 $el = $elements->item($i);
1263 if ($el && $el->parentNode) {
1264 $el->parentNode->removeChild($el);
1265 }
1266 }
1267 }
1268 }
1269
1270 $cleaned_content = $temp_dom->saveHTML();
1271 if (!empty($cleaned_content)) {
1272 $debug("Returning cleaned body content");
1273 return $cleaned_content;
1274 }
1275 }
1276
1277 // Last resort: return the original HTML
1278 $debug("Returning original HTML");
1279 return $html;
1280 } catch (Exception $e) {
1281 //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
1282 return $html; // Return original HTML if parsing fails
1283 } finally {
1284 libxml_clear_errors();
1285 }
1286 }
1287 public function mxchat_get_sitemap_processing_status($sitemap_url) {
1288 $sitemap_url = esc_url_raw($sitemap_url);
1289 $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
1290 $status = get_transient($status_key);
1291
1292 if (!$status || !is_array($status)) {
1293 return false;
1294 }
1295
1296 // Auto-complete check: if all URLs are processed but status isn't complete
1297 if (isset($status['processed_urls']) && isset($status['total_urls']) &&
1298 $status['processed_urls'] >= $status['total_urls'] &&
1299 isset($status['status']) && $status['status'] !== 'complete' &&
1300 $status['status'] !== 'error') {
1301
1302 // Mark as complete
1303 $status['status'] = 'complete';
1304 $status['processed_urls'] = $status['total_urls']; // Ensure exact match
1305
1306 // Update the transient with the corrected status
1307 set_transient($status_key, $status, DAY_IN_SECONDS);
1308 }
1309
1310 return array(
1311 'total_urls' => absint($status['total_urls']),
1312 'processed_urls' => absint($status['processed_urls']),
1313 'failed_urls' => absint($status['failed_urls'] ?? 0),
1314 'percentage' => ($status['total_urls'] > 0)
1315 ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
1316 : 0,
1317 'status' => sanitize_text_field($status['status']),
1318 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1319 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
1320 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
1321 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
1322 );
1323 }
1324
1325 public function mxchat_ajax_get_status_updates() {
1326 try {
1327 // Verify the request
1328 check_ajax_referer('mxchat_status_nonce', 'nonce');
1329
1330 // Get active queue IDs
1331 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1332 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1333
1334 $sitemap_status = false;
1335 $pdf_status = false;
1336
1337 // Get sitemap queue status
1338 if ($sitemap_queue_id) {
1339 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1340 }
1341
1342 // Get PDF queue status
1343 if ($pdf_queue_id) {
1344 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1345 }
1346
1347 $is_active_processing =
1348 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1349 ($pdf_status && $pdf_status['status'] === 'processing');
1350
1351 // Return JSON response with the status data
1352 wp_send_json(array(
1353 'pdf_status' => $pdf_status,
1354 'sitemap_status' => $sitemap_status,
1355 'is_processing' => $is_active_processing,
1356 'sitemap_queue_id' => $sitemap_queue_id,
1357 'pdf_queue_id' => $pdf_queue_id
1358 ));
1359
1360 } catch (Exception $e) {
1361 error_log('MxChat Status Update Error: ' . $e->getMessage());
1362
1363 wp_send_json_error(array(
1364 'message' => 'Error getting status updates: ' . $e->getMessage(),
1365 'status' => 'error'
1366 ));
1367 }
1368 }
1369
1370 /**
1371 * Helper function to get queue status data
1372 */
1373 private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
1374 global $wpdb;
1375 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
1376
1377 // Get counts by status
1378 $counts = $wpdb->get_results($wpdb->prepare(
1379 "SELECT status, COUNT(*) as count
1380 FROM $table_name
1381 WHERE queue_id = %s
1382 GROUP BY status",
1383 $queue_id
1384 ), OBJECT_K);
1385
1386 $total = 0;
1387 $completed = 0;
1388 $failed = 0;
1389 $processing = 0;
1390 $pending = 0;
1391
1392 foreach ($counts as $status => $data) {
1393 $count = absint($data->count);
1394 $total += $count;
1395
1396 switch ($status) {
1397 case 'completed':
1398 $completed = $count;
1399 break;
1400 case 'failed':
1401 $failed = $count;
1402 break;
1403 case 'processing':
1404 $processing = $count;
1405 break;
1406 case 'pending':
1407 $pending = $count;
1408 break;
1409 }
1410 }
1411
1412 if ($total === 0) {
1413 return false;
1414 }
1415
1416 // Calculate percentage
1417 $percentage = round((($completed + $failed) / $total) * 100);
1418
1419 // Get failed items details (limit to 50)
1420 $failed_items = array();
1421 if ($failed > 0) {
1422 $failed_results = $wpdb->get_results($wpdb->prepare(
1423 "SELECT item_type, item_data, error_message, attempts, completed_at
1424 FROM $table_name
1425 WHERE queue_id = %s
1426 AND status = 'failed'
1427 AND attempts >= max_attempts
1428 ORDER BY id DESC
1429 LIMIT 50",
1430 $queue_id
1431 ));
1432
1433 foreach ($failed_results as $item) {
1434 $data = json_decode($item->item_data, true);
1435 $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
1436
1437 $failed_items[] = array(
1438 'url' => $url,
1439 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
1440 'error' => $item->error_message,
1441 'retries' => $item->attempts,
1442 'time' => strtotime($item->completed_at)
1443 );
1444 }
1445 }
1446
1447 // Get queue metadata
1448 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
1449
1450 // Determine if queue is complete
1451 $is_complete = ($pending === 0 && $processing === 0);
1452
1453 // Get last update time
1454 $last_update = $wpdb->get_var($wpdb->prepare(
1455 "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
1456 FROM $table_name
1457 WHERE queue_id = %s",
1458 $queue_id
1459 ));
1460
1461 $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
1462
1463 // Format based on type
1464 if ($type === 'pdf') {
1465 return array(
1466 'total_pages' => $total,
1467 'processed_pages' => $completed + $failed,
1468 'failed_pages' => $failed,
1469 'percentage' => $percentage,
1470 'status' => $is_complete ? 'complete' : 'processing',
1471 'last_update' => $last_update_text,
1472 'failed_pages_list' => $failed_items,
1473 'pdf_url' => $source_url,
1474 'queue_id' => $queue_id
1475 );
1476 } else {
1477 return array(
1478 'total_urls' => $total,
1479 'processed_urls' => $completed + $failed,
1480 'failed_urls' => $failed,
1481 'percentage' => $percentage,
1482 'status' => $is_complete ? 'complete' : 'processing',
1483 'last_update' => $last_update_text,
1484 'failed_urls_list' => $failed_items,
1485 'sitemap_url' => $source_url,
1486 'queue_id' => $queue_id
1487 );
1488 }
1489 }
1490
1491 /**
1492 * Public method to get processing status for both sitemap and PDF queues
1493 * Used by admin pages to display processing status
1494 *
1495 * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
1496 */
1497 public function mxchat_get_processing_statuses() {
1498 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1499 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1500
1501 $sitemap_status = false;
1502 $pdf_status = false;
1503
1504 if ($sitemap_queue_id) {
1505 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1506 }
1507
1508 if ($pdf_queue_id) {
1509 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1510 }
1511
1512 $is_processing =
1513 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1514 ($pdf_status && $pdf_status['status'] === 'processing');
1515
1516 return array(
1517 'sitemap_status' => $sitemap_status,
1518 'pdf_status' => $pdf_status,
1519 'is_processing' => $is_processing
1520 );
1521 }
1522
1523 /**
1524 * AJAX handler to get recent knowledge entries for real-time table updates
1525 * UPDATED: Now supports both WordPress DB and Pinecone data sources
1526 */
1527 public function ajax_mxchat_get_recent_entries() {
1528 check_ajax_referer('mxchat_entries_nonce', 'nonce');
1529
1530 if (!current_user_can('manage_options')) {
1531 wp_send_json_error(array('message' => 'Unauthorized'));
1532 return;
1533 }
1534
1535 global $wpdb;
1536 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1537
1538 // Get parameters
1539 $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
1540 $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
1541 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1542
1543 // Check if Pinecone is enabled for this bot
1544 $pinecone_manager = $this->mxchat_get_pinecone_manager();
1545 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
1546 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1547 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
1548
1549 if ($use_pinecone && $has_pinecone_api) {
1550 // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
1551 // Use mxchat_fetch_pinecone_records which returns total_unique_entries
1552 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
1553 $total_count = $records['total'] ?? 0;
1554
1555 // For Pinecone, we don't return individual entries during polling
1556 // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
1557 // We just return the updated count
1558 wp_send_json_success(array(
1559 'entries' => array(),
1560 'total_count' => absint($total_count),
1561 'max_id' => $last_id,
1562 'data_source' => 'pinecone'
1563 ));
1564 return;
1565 }
1566
1567 // WORDPRESS DB DATA SOURCE
1568 // Build query to get entries newer than last_id
1569 $where_clauses = array('1=1');
1570 $where_values = array();
1571
1572 if ($last_id > 0) {
1573 $where_clauses[] = 'id > %d';
1574 $where_values[] = $last_id;
1575 }
1576
1577 // Note: WordPress DB table doesn't have bot_id column
1578 // Multi-bot filtering is handled via Pinecone namespaces
1579
1580 $where_sql = implode(' AND ', $where_clauses);
1581
1582 // Get recent entries
1583 $query = "SELECT id, article_content, source_url, timestamp
1584 FROM $table_name
1585 WHERE $where_sql
1586 ORDER BY id DESC
1587 LIMIT %d";
1588
1589 $where_values[] = $limit;
1590
1591 $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
1592
1593 // Get total count of GROUPED entries (by source_url) - matches pagination display
1594 // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
1595 $total_count = $wpdb->get_var(
1596 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1597 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
1598 );
1599
1600 // Format entries for response
1601 $formatted_entries = array();
1602 $preview_length = 150;
1603 foreach ($entries as $entry) {
1604 // Parse chunk metadata using the proper chunker method (same as initial page load)
1605 if (class_exists('MxChat_Chunker')) {
1606 $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
1607 $display_content = $chunk_meta['text'];
1608 $chunk_metadata = $chunk_meta['metadata'];
1609 } else {
1610 $display_content = $entry->article_content;
1611 $chunk_metadata = array();
1612 }
1613
1614 $content_preview = mb_strlen($display_content) > $preview_length
1615 ? mb_substr($display_content, 0, $preview_length) . '...'
1616 : $display_content;
1617
1618 $formatted_entries[] = array(
1619 'id' => $entry->id,
1620 'preview' => esc_html($content_preview),
1621 'full_content' => wp_kses_post(wpautop($display_content)),
1622 'content_length' => mb_strlen($display_content),
1623 'preview_length' => $preview_length,
1624 'source_url' => $entry->source_url,
1625 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
1626 'chunk_metadata' => $chunk_metadata,
1627 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
1628 );
1629 }
1630
1631 wp_send_json_success(array(
1632 'entries' => $formatted_entries,
1633 'total_count' => absint($total_count),
1634 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
1635 'data_source' => 'wordpress'
1636 ));
1637 }
1638
1639 /**
1640 * Get Pinecone total count from stats API
1641 * Helper function for ajax_mxchat_get_recent_entries
1642 */
1643 private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
1644 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1645 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1646 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1647
1648 if (empty($api_key) || empty($host)) {
1649 return 0;
1650 }
1651
1652 try {
1653 $stats_url = "https://{$host}/describe_index_stats";
1654
1655 $response = wp_remote_post($stats_url, array(
1656 'headers' => array(
1657 'Api-Key' => $api_key,
1658 'Content-Type' => 'application/json'
1659 ),
1660 'body' => '{}',
1661 'timeout' => 10
1662 ));
1663
1664 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1665 $body = wp_remote_retrieve_body($response);
1666 $stats_data = json_decode($body, true);
1667
1668 // If namespace is specified, get count from that specific namespace
1669 if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
1670 return intval($stats_data['namespaces'][$namespace]['vectorCount']);
1671 }
1672
1673 // If no namespace specified or namespace not found in response, use total
1674 return intval($stats_data['totalVectorCount'] ?? 0);
1675 }
1676
1677 return 0;
1678
1679 } catch (Exception $e) {
1680 return 0;
1681 }
1682 }
1683
1684 /**
1685 * AJAX handler to refresh Pinecone entries table via AJAX
1686 * Returns the table HTML for updating the UI without a full page reload
1687 */
1688 public function ajax_mxchat_refresh_pinecone_entries() {
1689 check_ajax_referer('mxchat_entries_nonce', 'nonce');
1690
1691 if (!current_user_can('manage_options')) {
1692 wp_send_json_error(array('message' => 'Unauthorized'));
1693 return;
1694 }
1695
1696 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1697 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
1698 $per_page = 25;
1699 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
1700 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
1701
1702 // Get Pinecone manager and options
1703 $pinecone_manager = $this->mxchat_get_pinecone_manager();
1704 if (!$pinecone_manager) {
1705 wp_send_json_error(array('message' => 'Pinecone manager not available'));
1706 return;
1707 }
1708
1709 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
1710 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1711 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1712
1713 if (!$use_pinecone || empty($pinecone_api_key)) {
1714 wp_send_json_error(array('message' => 'Pinecone not configured'));
1715 return;
1716 }
1717
1718 // Fetch records from Pinecone
1719 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
1720 $prompts = $records['data'] ?? array();
1721 $total_records = $records['total'] ?? 0;
1722
1723 // Preprocess Pinecone records — set chunk_metadata and display_content
1724 // (matches admin-knowledge-page.php preprocessing)
1725 foreach ($prompts as $prompt) {
1726 if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
1727 $prompt->chunk_metadata = array(
1728 'chunk_index' => intval($prompt->chunk_index),
1729 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
1730 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
1731 'source_url' => $prompt->source_url ?? ''
1732 );
1733 $prompt->display_content = $prompt->article_content;
1734 } else {
1735 $prompt->chunk_metadata = array();
1736 $prompt->display_content = $prompt->article_content ?? '';
1737 }
1738 }
1739
1740 // Group prompts by source_url
1741 $grouped_prompts = array();
1742 foreach ($prompts as $prompt) {
1743 $source_url = '';
1744 if (!empty($prompt->chunk_metadata['source_url'])) {
1745 $source_url = $prompt->chunk_metadata['source_url'];
1746 } elseif (!empty($prompt->source_url)) {
1747 $source_url = $prompt->source_url;
1748 }
1749
1750 if (!empty($source_url)) {
1751 if (!isset($grouped_prompts[$source_url])) {
1752 $grouped_prompts[$source_url] = array();
1753 }
1754 $grouped_prompts[$source_url][] = $prompt;
1755 } else {
1756 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
1757 }
1758 }
1759
1760 // Sort each group by chunk_index
1761 foreach ($grouped_prompts as $source_url => &$group) {
1762 usort($group, function($a, $b) {
1763 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
1764 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
1765 return $index_a - $index_b;
1766 });
1767 }
1768 unset($group);
1769
1770 // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
1771 ob_start();
1772 $display_index = 0;
1773 $current_page = $page;
1774 $data_source = 'pinecone';
1775 $current_bot_id = $bot_id;
1776 $preview_length = 150;
1777
1778 if (empty($grouped_prompts)) {
1779 echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
1780 esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
1781 echo '</td></tr>';
1782 } else {
1783 foreach ($grouped_prompts as $source_url => $group) {
1784 $chunk_count = count($group);
1785 $first_prompt = $group[0];
1786 $display_index++;
1787
1788 if ($chunk_count > 1) {
1789 // Multiple chunks - show grouped row with expand button
1790 $group_id = 'group-' . md5($source_url);
1791 ?>
1792 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
1793 class="mxchat-chunk-group-header"
1794 data-source="<?php echo esc_attr($data_source); ?>"
1795 data-group-id="<?php echo esc_attr($group_id); ?>"
1796 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
1797 <td style="padding: 12px 16px; text-align: center;">
1798 <input type="checkbox"
1799 class="mxchat-entry-checkbox"
1800 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
1801 data-source="<?php echo esc_attr($data_source); ?>"
1802 data-source-url="<?php echo esc_attr($source_url); ?>"
1803 data-is-group="true"
1804 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
1805 </td>
1806 <td style="padding: 12px 16px; font-size: 13px;">
1807 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
1808 </td>
1809 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1810 <div class="mxchat-chunk-group-info">
1811 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
1812 <span class="dashicons dashicons-arrow-right-alt2"></span>
1813 </button>
1814 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
1815 <span class="mxchat-chunk-preview">
1816 <?php
1817 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
1818 $content_preview = mb_substr($parent_content, 0, 100);
1819 echo esc_html($content_preview . '...');
1820 ?>
1821 </span>
1822 </div>
1823 </td>
1824 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1825 <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
1826 <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
1827 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
1828 <?php esc_html_e('View Source', 'mxchat'); ?>
1829 </a>
1830 <?php else : ?>
1831 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
1832 <?php endif; ?>
1833 </td>
1834 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
1835 <button type="button"
1836 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
1837 data-source-url="<?php echo esc_attr($source_url); ?>"
1838 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
1839 data-data-source="<?php echo esc_attr($data_source); ?>"
1840 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
1841 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
1842 style="color: var(--mxch-error);"
1843 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
1844 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
1845 </button>
1846 </td>
1847 </tr>
1848 <?php
1849 // Render hidden chunk rows
1850 foreach ($group as $chunk_index => $chunk) {
1851 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
1852 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
1853 $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
1854 $content_preview = mb_strlen($content) > $preview_length
1855 ? mb_substr($content, 0, $preview_length) . '...'
1856 : $content;
1857 ?>
1858 <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
1859 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
1860 data-source="<?php echo esc_attr($data_source); ?>"
1861 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
1862 <td style="padding: 12px 16px; text-align: center;">
1863 <!-- Checkbox column placeholder for chunks (managed by group) -->
1864 </td>
1865 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
1866 <!-- Hidden ID column for chunks -->
1867 </td>
1868 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1869 <div class="mxchat-accordion-wrapper">
1870 <div class="mxchat-content-preview">
1871 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
1872 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
1873 </span>
1874 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
1875 <?php if (mb_strlen($content) > $preview_length) : ?>
1876 <button class="mxchat-expand-toggle" type="button">
1877 <span class="dashicons dashicons-arrow-down-alt2"></span>
1878 </button>
1879 <?php endif; ?>
1880 </div>
1881 <div class="mxchat-content-full" style="display: none;">
1882 <div class="content-view">
1883 <?php
1884 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
1885 echo '<div dir="rtl" lang="he" class="rtl-content">';
1886 echo wp_kses_post(wpautop($content));
1887 echo '</div>';
1888 } else {
1889 echo wp_kses_post(wpautop($content));
1890 }
1891 ?>
1892 </div>
1893 </div>
1894 </div>
1895 </td>
1896 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1897 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
1898 </td>
1899 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
1900 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
1901 </td>
1902 </tr>
1903 <?php
1904 }
1905 } else {
1906 // Single entry - display normally with accordion
1907 $prompt = $first_prompt;
1908 $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
1909 $content_preview = mb_strlen($content) > $preview_length
1910 ? mb_substr($content, 0, $preview_length) . '...'
1911 : $content;
1912 ?>
1913 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
1914 data-source="<?php echo esc_attr($data_source); ?>"
1915 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
1916 <td style="padding: 12px 16px; text-align: center;">
1917 <input type="checkbox"
1918 class="mxchat-entry-checkbox"
1919 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
1920 data-source="<?php echo esc_attr($data_source); ?>"
1921 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
1922 data-is-group="false"
1923 data-chunk-count="1">
1924 </td>
1925 <td style="padding: 12px 16px; font-size: 13px;">
1926 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
1927 </td>
1928 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1929 <div class="mxchat-accordion-wrapper">
1930 <div class="mxchat-content-preview">
1931 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
1932 <?php if (mb_strlen($content) > $preview_length) : ?>
1933 <button class="mxchat-expand-toggle" type="button">
1934 <span class="dashicons dashicons-arrow-down-alt2"></span>
1935 </button>
1936 <?php endif; ?>
1937 </div>
1938 <div class="mxchat-content-full" style="display: none;">
1939 <div class="content-view">
1940 <?php
1941 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
1942 echo '<div dir="rtl" lang="he" class="rtl-content">';
1943 echo wp_kses_post(wpautop($content));
1944 echo '</div>';
1945 } else {
1946 echo wp_kses_post(wpautop($content));
1947 }
1948 ?>
1949 </div>
1950 </div>
1951 </div>
1952 </td>
1953 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1954 <?php
1955 $actual_source = $source_url;
1956 if (strpos($source_url, '_ungrouped_') === 0) {
1957 $actual_source = $prompt->source_url ?? '';
1958 }
1959 if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
1960 <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
1961 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
1962 <?php esc_html_e('View', 'mxchat'); ?>
1963 </a>
1964 <?php else : ?>
1965 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
1966 <?php endif; ?>
1967 </td>
1968 <td style="padding: 12px 16px;">
1969 <button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-ajax" data-vector-id="<?php echo esc_attr($prompt->id); ?>" data-bot-id="<?php echo esc_attr($current_bot_id); ?>" data-nonce="<?php echo wp_create_nonce('mxchat_delete_pinecone_prompt_nonce'); ?>" style="color: var(--mxch-error);">
1970 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
1971 </button>
1972 </td>
1973 </tr>
1974 <?php
1975 }
1976 }
1977 }
1978 $html = ob_get_clean();
1979
1980 // Generate pagination HTML for Pinecone
1981 $total_pages = ceil($total_records / $per_page);
1982 $pagination_html = '';
1983 if ($total_pages > 1) {
1984 $pagination_html = '<div class="mxchat-ajax-pagination" data-current-page="' . esc_attr($page) . '" data-total-pages="' . esc_attr($total_pages) . '" data-search="' . esc_attr($search_query) . '" data-content-type="' . esc_attr($content_type_filter) . '">';
1985
1986 // Previous button
1987 if ($page > 1) {
1988 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
1989 }
1990
1991 // Page numbers
1992 $start_page = max(1, $page - 2);
1993 $end_page = min($total_pages, $page + 2);
1994
1995 if ($start_page > 1) {
1996 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
1997 if ($start_page > 2) {
1998 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
1999 }
2000 }
2001
2002 for ($i = $start_page; $i <= $end_page; $i++) {
2003 if ($i == $page) {
2004 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2005 } else {
2006 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2007 }
2008 }
2009
2010 if ($end_page < $total_pages) {
2011 if ($end_page < $total_pages - 1) {
2012 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2013 }
2014 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2015 }
2016
2017 // Next button
2018 if ($page < $total_pages) {
2019 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2020 }
2021
2022 $pagination_html .= '</div>';
2023 }
2024
2025 wp_send_json_success(array(
2026 'html' => $html,
2027 'pagination_html' => $pagination_html,
2028 'total_count' => $total_records,
2029 'total_pages' => $total_pages,
2030 'page' => $page,
2031 'per_page' => $per_page,
2032 'data_source' => 'pinecone'
2033 ));
2034 }
2035
2036 /**
2037 * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
2038 * Returns paginated entries without requiring a full page reload
2039 */
2040 public function ajax_mxchat_paginate_entries() {
2041 check_ajax_referer('mxchat_entries_nonce', 'nonce');
2042
2043 if (!current_user_can('manage_options')) {
2044 wp_send_json_error(array('message' => 'Unauthorized'));
2045 return;
2046 }
2047
2048 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2049 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2050 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2051 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2052 $per_page = 25;
2053
2054 // Check if Pinecone is enabled for this bot
2055 $pinecone_manager = $this->mxchat_get_pinecone_manager();
2056 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2057 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2058 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2059
2060 if ($use_pinecone && $has_pinecone_api) {
2061 // Delegate to Pinecone pagination handler (pass search params)
2062 $_POST['page'] = $page;
2063 $_POST['search'] = $search_query;
2064 $_POST['content_type'] = $content_type_filter;
2065 $this->ajax_mxchat_refresh_pinecone_entries();
2066 return;
2067 }
2068
2069 // WordPress DB pagination - MUST match initial page load logic exactly
2070 global $wpdb;
2071 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2072 $offset = ($page - 1) * $per_page;
2073
2074 // Build WHERE clause for search and content type filtering
2075 $where_clauses = array();
2076 $where_values = array();
2077
2078 if ($search_query) {
2079 $where_clauses[] = "article_content LIKE %s";
2080 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
2081 }
2082
2083 if ($content_type_filter) {
2084 switch ($content_type_filter) {
2085 case 'manual':
2086 $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
2087 break;
2088 case 'pdf':
2089 $where_clauses[] = "source_url LIKE '%.pdf'";
2090 break;
2091 case 'url':
2092 $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
2093 break;
2094 }
2095 }
2096
2097 $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
2098
2099 // Count grouped entries with filters applied
2100 if (!empty($where_values)) {
2101 $count_args = array_merge($where_values, $where_values);
2102 $count_query = $wpdb->prepare(
2103 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2104 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
2105 ...$count_args
2106 );
2107 $total_records = $wpdb->get_var($count_query);
2108 } else if (!empty($where_sql)) {
2109 // Content type filter only (no search), no prepared values needed
2110 $total_records = $wpdb->get_var(
2111 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2112 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
2113 );
2114 } else {
2115 // No filters
2116 $total_records = $wpdb->get_var(
2117 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2118 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2119 );
2120 }
2121 $total_pages = ceil($total_records / $per_page);
2122
2123 // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
2124 if (!empty($where_values)) {
2125 $query_args = array_merge($where_values, array($per_page, $offset));
2126 $urls_query = $wpdb->prepare(
2127 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2128 {$where_sql}
2129 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2130 ...$query_args
2131 );
2132 } else if (!empty($where_sql)) {
2133 $urls_query = $wpdb->prepare(
2134 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2135 {$where_sql}
2136 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2137 $per_page, $offset
2138 );
2139 } else {
2140 $urls_query = $wpdb->prepare(
2141 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2142 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2143 $per_page, $offset
2144 );
2145 }
2146 $page_urls = $wpdb->get_results($urls_query);
2147
2148 // Step 2: Build list of source_urls to fetch
2149 $url_list = array();
2150 $url_order_map = array();
2151 $order_index = 0;
2152 foreach ($page_urls as $url_row) {
2153 $url = $url_row->source_url;
2154 $url_list[] = $url;
2155 $url_order_map[$url] = $order_index++;
2156 }
2157
2158 // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
2159 $prompts = array();
2160 if (!empty($url_list)) {
2161 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
2162 if ($search_query) {
2163 // Include search filter in the final fetch
2164 $prompts_query = $wpdb->prepare(
2165 "SELECT id, article_content, source_url, timestamp, role_restriction
2166 FROM {$table_name}
2167 WHERE source_url IN ($placeholders) AND article_content LIKE %s
2168 ORDER BY timestamp DESC",
2169 ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
2170 );
2171 } else {
2172 $prompts_query = $wpdb->prepare(
2173 "SELECT id, article_content, source_url, timestamp, role_restriction
2174 FROM {$table_name}
2175 WHERE source_url IN ($placeholders)
2176 ORDER BY timestamp DESC",
2177 $url_list
2178 );
2179 }
2180 $prompts = $wpdb->get_results($prompts_query);
2181 }
2182
2183 // Group prompts by source_url for chunk display
2184 $grouped_prompts = array();
2185 foreach ($prompts as $prompt) {
2186 $source_url = $prompt->source_url ?? '';
2187
2188 // Parse chunk metadata using the proper chunker method (same as initial page load)
2189 if (class_exists('MxChat_Chunker')) {
2190 $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
2191 $prompt->chunk_metadata = $chunk_meta['metadata'];
2192 $prompt->display_content = $chunk_meta['text'];
2193 } else {
2194 $prompt->chunk_metadata = array();
2195 $prompt->display_content = $prompt->article_content;
2196 }
2197
2198 if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
2199 if (!isset($grouped_prompts[$source_url])) {
2200 $grouped_prompts[$source_url] = array();
2201 }
2202 $grouped_prompts[$source_url][] = $prompt;
2203 } else {
2204 // Ungrouped entries
2205 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2206 }
2207 }
2208
2209 // Sort groups by the original URL order (newest first)
2210 uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
2211 $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
2212 $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
2213 return $order_a - $order_b;
2214 });
2215
2216 // Sort each group internally by chunk_index
2217 foreach ($grouped_prompts as $source_url => &$group) {
2218 usort($group, function($a, $b) {
2219 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2220 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2221 return $index_a - $index_b;
2222 });
2223 }
2224 unset($group);
2225
2226 // Build HTML for the table rows
2227 ob_start();
2228 $display_index = 0;
2229 $current_page = $page;
2230 $data_source = 'wordpress';
2231 $current_bot_id = $bot_id;
2232 $preview_length = 150;
2233
2234 if (empty($grouped_prompts)) {
2235 echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2236 esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
2237 echo '</td></tr>';
2238 } else {
2239 foreach ($grouped_prompts as $source_url => $group) {
2240 $chunk_count = count($group);
2241 $first_prompt = $group[0];
2242 $display_index++;
2243
2244 if ($chunk_count > 1) {
2245 // Multiple chunks - show grouped row with expand button
2246 $group_id = 'group-' . md5($source_url);
2247 ?>
2248 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2249 class="mxchat-chunk-group-header"
2250 data-source="<?php echo esc_attr($data_source); ?>"
2251 data-group-id="<?php echo esc_attr($group_id); ?>"
2252 style="border-bottom: 1px solid var(--mxch-card-border);">
2253 <td style="padding: 12px 16px; text-align: center;">
2254 <input type="checkbox"
2255 class="mxchat-entry-checkbox"
2256 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2257 data-source="<?php echo esc_attr($data_source); ?>"
2258 data-source-url="<?php echo esc_attr($source_url); ?>"
2259 data-is-group="true"
2260 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2261 </td>
2262 <td style="padding: 12px 16px; font-size: 13px;">
2263 <?php echo esc_html($first_prompt->id); ?>
2264 </td>
2265 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2266 <div class="mxchat-chunk-group-info">
2267 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2268 <span class="dashicons dashicons-arrow-right-alt2"></span>
2269 </button>
2270 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2271 <span class="mxchat-chunk-preview">
2272 <?php
2273 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
2274 $content_preview = mb_substr($parent_content, 0, 100);
2275 echo esc_html($content_preview . '...');
2276 ?>
2277 </span>
2278 </div>
2279 </td>
2280 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2281 <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2282 <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2283 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2284 <?php esc_html_e('View Source', 'mxchat'); ?>
2285 </a>
2286 <?php else : ?>
2287 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2288 <?php endif; ?>
2289 </td>
2290 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2291 <button type="button"
2292 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2293 data-source-url="<?php echo esc_attr($source_url); ?>"
2294 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2295 data-data-source="<?php echo esc_attr($data_source); ?>"
2296 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2297 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2298 style="color: var(--mxch-error);"
2299 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2300 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2301 </button>
2302 </td>
2303 </tr>
2304 <?php
2305 // Render hidden chunk rows
2306 foreach ($group as $chunk_index => $chunk) {
2307 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2308 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2309 $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
2310 $content_preview = mb_strlen($content) > $preview_length
2311 ? mb_substr($content, 0, $preview_length) . '...'
2312 : $content;
2313 ?>
2314 <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2315 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2316 data-source="<?php echo esc_attr($data_source); ?>"
2317 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2318 <td style="padding: 12px 16px; text-align: center;">
2319 <!-- Checkbox column placeholder for chunks (managed by group) -->
2320 </td>
2321 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2322 <!-- Hidden ID column for chunks -->
2323 </td>
2324 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2325 <div class="mxchat-accordion-wrapper">
2326 <div class="mxchat-content-preview">
2327 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2328 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2329 </span>
2330 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2331 <?php if (mb_strlen($content) > $preview_length) : ?>
2332 <button class="mxchat-expand-toggle" type="button">
2333 <span class="dashicons dashicons-arrow-down-alt2"></span>
2334 </button>
2335 <?php endif; ?>
2336 </div>
2337 <div class="mxchat-content-full" style="display: none;">
2338 <div class="content-view">
2339 <?php
2340 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2341 echo '<div dir="rtl" lang="he" class="rtl-content">';
2342 echo wp_kses_post(wpautop($content));
2343 echo '</div>';
2344 } else {
2345 echo wp_kses_post(wpautop($content));
2346 }
2347 ?>
2348 </div>
2349 </div>
2350 </div>
2351 </td>
2352 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2353 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2354 </td>
2355 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2356 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2357 </td>
2358 </tr>
2359 <?php
2360 }
2361 } else {
2362 // Single entry - display normally with accordion
2363 $prompt = $first_prompt;
2364 $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
2365 $content_preview = mb_strlen($content) > $preview_length
2366 ? mb_substr($content, 0, $preview_length) . '...'
2367 : $content;
2368 ?>
2369 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2370 data-source="<?php echo esc_attr($data_source); ?>"
2371 style="border-bottom: 1px solid var(--mxch-card-border);">
2372 <td style="padding: 12px 16px; text-align: center;">
2373 <input type="checkbox"
2374 class="mxchat-entry-checkbox"
2375 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2376 data-source="<?php echo esc_attr($data_source); ?>"
2377 data-source-url="<?php echo esc_attr($source_url); ?>"
2378 data-is-group="false">
2379 </td>
2380 <td style="padding: 12px 16px; font-size: 13px;">
2381 <?php echo esc_html($prompt->id); ?>
2382 </td>
2383 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2384 <div class="mxchat-accordion-wrapper">
2385 <div class="mxchat-content-preview">
2386 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2387 <?php if (mb_strlen($content) > $preview_length) : ?>
2388 <button class="mxchat-expand-toggle" type="button">
2389 <span class="dashicons dashicons-arrow-down-alt2"></span>
2390 </button>
2391 <?php endif; ?>
2392 </div>
2393 <div class="mxchat-content-full" style="display: none;">
2394 <div class="content-view">
2395 <?php
2396 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2397 echo '<div dir="rtl" lang="he" class="rtl-content">';
2398 echo wp_kses_post(wpautop($content));
2399 echo '</div>';
2400 } else {
2401 echo wp_kses_post(wpautop($content));
2402 }
2403 ?>
2404 </div>
2405 </div>
2406 </div>
2407 </td>
2408 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2409 <?php
2410 $actual_source = $source_url;
2411 if (strpos($source_url, '_ungrouped_') === 0) {
2412 $actual_source = $prompt->source_url ?? '';
2413 }
2414 if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2415 <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2416 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2417 <?php esc_html_e('View', 'mxchat'); ?>
2418 </a>
2419 <?php else : ?>
2420 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2421 <?php endif; ?>
2422 </td>
2423 <td style="padding: 12px 16px;">
2424 <button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-wordpress" data-entry-id="<?php echo esc_attr($prompt->id); ?>" data-bot-id="<?php echo esc_attr($current_bot_id); ?>" data-nonce="<?php echo wp_create_nonce('mxchat_delete_wordpress_prompt_nonce'); ?>" style="color: var(--mxch-error);">
2425 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2426 </button>
2427 </td>
2428 </tr>
2429 <?php
2430 }
2431 }
2432 }
2433 $html = ob_get_clean();
2434
2435 // Generate pagination HTML (include search/filter data for subsequent pages)
2436 $pagination_html = '';
2437 if ($total_pages > 1) {
2438 $pagination_html = '<div class="mxchat-ajax-pagination" data-current-page="' . esc_attr($page) . '" data-total-pages="' . esc_attr($total_pages) . '" data-search="' . esc_attr($search_query) . '" data-content-type="' . esc_attr($content_type_filter) . '">';
2439
2440 // Previous button
2441 if ($page > 1) {
2442 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2443 }
2444
2445 // Page numbers
2446 $start_page = max(1, $page - 2);
2447 $end_page = min($total_pages, $page + 2);
2448
2449 if ($start_page > 1) {
2450 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2451 if ($start_page > 2) {
2452 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2453 }
2454 }
2455
2456 for ($i = $start_page; $i <= $end_page; $i++) {
2457 if ($i == $page) {
2458 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2459 } else {
2460 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2461 }
2462 }
2463
2464 if ($end_page < $total_pages) {
2465 if ($end_page < $total_pages - 1) {
2466 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2467 }
2468 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2469 }
2470
2471 // Next button
2472 if ($page < $total_pages) {
2473 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2474 }
2475
2476 $pagination_html .= '</div>';
2477 }
2478
2479 wp_send_json_success(array(
2480 'html' => $html,
2481 'pagination_html' => $pagination_html,
2482 'total_count' => $total_records,
2483 'total_pages' => $total_pages,
2484 'page' => $page,
2485 'per_page' => $per_page,
2486 'data_source' => 'wordpress'
2487 ));
2488 }
2489
2490 /**
2491 * AJAX handler to detect available sitemaps on the site
2492 * Optimized for speed - only checks primary sitemap indexes first
2493 */
2494 public function ajax_mxchat_detect_sitemaps() {
2495 check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
2496
2497 if (!current_user_can('manage_options')) {
2498 wp_send_json_error(array('message' => 'Unauthorized'));
2499 return;
2500 }
2501
2502 $site_url = get_site_url();
2503 $sitemaps = array();
2504 $found_index = false;
2505
2506 // Only check the main sitemap index files first (much faster)
2507 // These are the primary entry points that contain sub-sitemaps
2508 $primary_indexes = array(
2509 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
2510 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
2511 'sitemap.xml' => 'Standard', // Generic/AIOSEO
2512 );
2513
2514 foreach ($primary_indexes as $path => $source) {
2515 $url = trailingslashit($site_url) . $path;
2516
2517 $response = wp_remote_head($url, array(
2518 'timeout' => 3, // Short timeout
2519 'sslverify' => false,
2520 'redirection' => 1
2521 ));
2522
2523 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2524 // Found a sitemap index - parse it to get sub-sitemaps
2525 $sub_sitemaps = $this->parse_sitemap_index($url);
2526 if (!empty($sub_sitemaps)) {
2527 $sitemaps[] = array(
2528 'url' => $url,
2529 'type' => 'index',
2530 'source' => $source,
2531 'sub_sitemaps' => $sub_sitemaps
2532 );
2533 $found_index = true;
2534 // Found a valid index, no need to check others
2535 break;
2536 }
2537 }
2538 }
2539
2540 // If no sitemap index found, check for standalone sitemaps
2541 if (!$found_index) {
2542 $standalone_sitemaps = array(
2543 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2544 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2545 );
2546
2547 foreach ($standalone_sitemaps as $path => $info) {
2548 $url = trailingslashit($site_url) . $path;
2549
2550 $response = wp_remote_head($url, array(
2551 'timeout' => 2,
2552 'sslverify' => false
2553 ));
2554
2555 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2556 $sitemaps[] = array(
2557 'url' => $url,
2558 'type' => $info['type'],
2559 'source' => $info['source'],
2560 'url_count' => 0 // Skip URL count for speed
2561 );
2562 }
2563 }
2564 }
2565
2566 wp_send_json_success(array(
2567 'sitemaps' => $sitemaps,
2568 'site_url' => $site_url
2569 ));
2570 }
2571
2572 /**
2573 * Parse a sitemap index to get sub-sitemaps
2574 * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
2575 */
2576 private function parse_sitemap_index($url) {
2577 $sub_sitemaps = array();
2578
2579 $response = wp_remote_get($url, array(
2580 'timeout' => 5,
2581 'sslverify' => false
2582 ));
2583
2584 if (is_wp_error($response)) {
2585 return $sub_sitemaps;
2586 }
2587
2588 $body = wp_remote_retrieve_body($response);
2589 if (empty($body)) {
2590 return $sub_sitemaps;
2591 }
2592
2593 // Suppress XML errors
2594 libxml_use_internal_errors(true);
2595 $xml = simplexml_load_string($body);
2596 libxml_clear_errors();
2597
2598 if ($xml === false) {
2599 return $sub_sitemaps;
2600 }
2601
2602 // Check if it's a sitemap index (contains <sitemap> elements)
2603 if (isset($xml->sitemap)) {
2604 foreach ($xml->sitemap as $sitemap) {
2605 $loc = (string) $sitemap->loc;
2606 if (!empty($loc)) {
2607 // Try to determine the type from the URL
2608 $type = 'content';
2609 if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
2610 $type = 'taxonomy';
2611 } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
2612 $type = 'author';
2613 }
2614
2615 // Skip URL count - too slow to fetch for each sitemap
2616 $sub_sitemaps[] = array(
2617 'url' => $loc,
2618 'type' => $type,
2619 'url_count' => 0, // Don't fetch - takes too long
2620 'name' => basename(parse_url($loc, PHP_URL_PATH))
2621 );
2622 }
2623 }
2624 }
2625
2626 return $sub_sitemaps;
2627 }
2628
2629 /**
2630 * Get URL count from a sitemap
2631 */
2632 private function get_sitemap_url_count($url) {
2633 $response = wp_remote_get($url, array(
2634 'timeout' => 10,
2635 'sslverify' => false
2636 ));
2637
2638 if (is_wp_error($response)) {
2639 return 0;
2640 }
2641
2642 $body = wp_remote_retrieve_body($response);
2643 if (empty($body)) {
2644 return 0;
2645 }
2646
2647 // Count <url> or <loc> elements
2648 $count = preg_match_all('/<url>/i', $body, $matches);
2649 return $count ?: 0;
2650 }
2651
2652 /**
2653 * Get sitemaps declared in robots.txt
2654 */
2655 private function get_sitemaps_from_robots($site_url) {
2656 $sitemaps = array();
2657 $robots_url = trailingslashit($site_url) . 'robots.txt';
2658
2659 $response = wp_remote_get($robots_url, array(
2660 'timeout' => 5,
2661 'sslverify' => false
2662 ));
2663
2664 if (is_wp_error($response)) {
2665 return $sitemaps;
2666 }
2667
2668 $body = wp_remote_retrieve_body($response);
2669 if (empty($body)) {
2670 return $sitemaps;
2671 }
2672
2673 // Find Sitemap: declarations
2674 if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
2675 foreach ($matches[1] as $sitemap_url) {
2676 $sitemap_url = trim($sitemap_url);
2677 if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
2678 $sitemaps[] = $sitemap_url;
2679 }
2680 }
2681 }
2682
2683 return $sitemaps;
2684 }
2685
2686 public function mxchat_stop_processing() {
2687 // Verify permissions
2688 if (!current_user_can('manage_options')) {
2689 wp_die(esc_html__('Unauthorized access', 'mxchat'));
2690 }
2691
2692 // Verify nonce
2693 check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
2694
2695 global $wpdb;
2696 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2697
2698 // Get active queue IDs
2699 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2700 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2701
2702 // Delete all pending items from active queues
2703 if ($sitemap_queue_id) {
2704 $wpdb->delete(
2705 $table_name,
2706 array(
2707 'queue_id' => $sitemap_queue_id,
2708 'status' => 'pending'
2709 ),
2710 array('%s', '%s')
2711 );
2712
2713 delete_transient('mxchat_active_queue_sitemap');
2714 delete_transient('mxchat_last_sitemap_url');
2715 }
2716
2717 if ($pdf_queue_id) {
2718 // Get PDF path before deleting
2719 $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
2720
2721 $wpdb->delete(
2722 $table_name,
2723 array(
2724 'queue_id' => $pdf_queue_id,
2725 'status' => 'pending'
2726 ),
2727 array('%s', '%s')
2728 );
2729
2730 // Delete PDF file
2731 if ($pdf_path && file_exists($pdf_path)) {
2732 wp_delete_file($pdf_path);
2733 }
2734
2735 delete_transient('mxchat_active_queue_pdf');
2736 delete_transient('mxchat_last_pdf_url');
2737 }
2738
2739 // Redirect back with a success message
2740 set_transient('mxchat_admin_notice_success',
2741 esc_html__('Processing has been stopped successfully.', 'mxchat'),
2742 30
2743 );
2744 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
2745 exit;
2746 }
2747
2748 /**
2749 * Get content list for processing
2750 */
2751 public function ajax_mxchat_get_content_list() {
2752 // Verify the nonce
2753 check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
2754
2755 if (!current_user_can('manage_options')) {
2756 wp_send_json_error(__('Unauthorized access', 'mxchat'));
2757 }
2758
2759 $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
2760 $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
2761 $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
2762 $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
2763 $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
2764 $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
2765
2766 // Build query args
2767 $args = array(
2768 'posts_per_page' => $per_page,
2769 'paged' => $page,
2770 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
2771 'orderby' => 'date',
2772 'order' => 'DESC',
2773 );
2774
2775 // Handle post types - IMPROVED VERSION
2776 if ($post_type !== 'all') {
2777 $args['post_type'] = $post_type;
2778 } else {
2779 // Get all available post types that might contain content
2780 $all_post_types = array();
2781
2782 // First get all public post types
2783 $public_types = get_post_types(array('public' => true), 'names');
2784 $all_post_types = array_merge($all_post_types, $public_types);
2785
2786 // Add common forum/community post types
2787 $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
2788 foreach ($forum_types as $forum_type) {
2789 if (post_type_exists($forum_type)) {
2790 $all_post_types[] = $forum_type;
2791 }
2792 }
2793
2794 // Add other commonly used post types
2795 $common_types = array('product', 'job_listing', 'event', 'portfolio');
2796 foreach ($common_types as $common_type) {
2797 if (post_type_exists($common_type)) {
2798 $all_post_types[] = $common_type;
2799 }
2800 }
2801
2802 // Remove duplicates and ensure we have at least some post types
2803 $all_post_types = array_unique($all_post_types);
2804
2805 if (empty($all_post_types)) {
2806 // Fallback to basic post types
2807 $all_post_types = array('post', 'page');
2808 }
2809
2810 $args['post_type'] = $all_post_types;
2811
2812 // Debug logging to see what post types are being queried
2813 //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
2814 }
2815
2816 if (!empty($search)) {
2817 $args['s'] = $search;
2818 }
2819
2820 // Get processed data from storage
2821 $processed_data = array();
2822
2823 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
2824 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2825
2826 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
2827 // Get fresh data from Pinecone - no caching
2828 $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
2829 } else {
2830 // WordPress DB checking with better URL matching for all post types
2831 global $wpdb;
2832 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2833 $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
2834
2835 // Group items by source_url to count chunks
2836 $url_chunk_counts = array();
2837 $url_latest_timestamp = array();
2838 $url_first_id = array();
2839
2840 if (!empty($processed_items)) {
2841 foreach ($processed_items as $item) {
2842 $url = $item->source_url;
2843 if (empty($url)) continue;
2844
2845 // Count chunks per URL
2846 if (!isset($url_chunk_counts[$url])) {
2847 $url_chunk_counts[$url] = 0;
2848 $url_latest_timestamp[$url] = $item->timestamp;
2849 $url_first_id[$url] = $item->id;
2850 }
2851 $url_chunk_counts[$url]++;
2852
2853 // Track latest timestamp
2854 if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
2855 $url_latest_timestamp[$url] = $item->timestamp;
2856 }
2857 }
2858
2859 // Now build processed_data with chunk counts
2860 foreach ($url_chunk_counts as $url => $chunk_count) {
2861 $post_id = $this->mxchat_url_to_post_id_improved($url);
2862
2863 if ($post_id) {
2864 $processed_data[$post_id] = array(
2865 'db_id' => $url_first_id[$url],
2866 'timestamp' => $url_latest_timestamp[$url],
2867 'url' => $url,
2868 'source' => 'wordpress',
2869 'chunk_count' => $chunk_count
2870 );
2871 }
2872 }
2873 }
2874 }
2875
2876 // Get processed IDs as a simple array for in_array checks
2877 $processed_ids = array_keys($processed_data);
2878
2879 // Handle processed/unprocessed filter
2880 if ($processed_filter === 'processed' && !empty($processed_ids)) {
2881 $args['post__in'] = $processed_ids;
2882 } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
2883 $args['post__not_in'] = $processed_ids;
2884 }
2885
2886 // Run the query
2887 $query = new WP_Query($args);
2888 $content_items = array();
2889
2890 if ($query->have_posts()) {
2891 while ($query->have_posts()) {
2892 $query->the_post();
2893 $id = get_the_ID();
2894 $post_date = get_the_date();
2895 $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
2896 $word_count = str_word_count(strip_tags(get_the_content()));
2897
2898 $is_processed = in_array($id, $processed_ids);
2899 $processed_date = '';
2900 $db_record_id = 0;
2901 $data_source = 'none';
2902
2903 if ($is_processed && isset($processed_data[$id])) {
2904 $item_data = $processed_data[$id];
2905 $data_source = $item_data['source'];
2906
2907 if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
2908 // WordPress DB format
2909 $timestamp = strtotime($item_data['timestamp']);
2910 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
2911 $db_record_id = $item_data['db_id'];
2912 } elseif ($data_source === 'pinecone') {
2913 // Pinecone format
2914 $processed_date = $item_data['processed_date'];
2915 $db_record_id = $item_data['db_id'];
2916 }
2917 }
2918
2919 // Get chunk count for this item
2920 $chunk_count = 0;
2921 if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
2922 $chunk_count = intval($processed_data[$id]['chunk_count']);
2923 }
2924
2925 $content_items[] = array(
2926 'id' => $id,
2927 'title' => get_the_title(),
2928 'permalink' => get_permalink(),
2929 'date' => $post_date,
2930 'type' => get_post_type(),
2931 'status' => get_post_status(),
2932 'excerpt' => $excerpt,
2933 'word_count' => $word_count,
2934 'already_processed' => $is_processed,
2935 'processed_date' => $processed_date,
2936 'db_record_id' => $db_record_id,
2937 'data_source' => $data_source,
2938 'chunk_count' => $chunk_count
2939 );
2940 }
2941 wp_reset_postdata();
2942 }
2943
2944 $response = array(
2945 'items' => $content_items,
2946 'total' => $query->found_posts,
2947 'total_pages' => $query->max_num_pages,
2948 'current_page' => $page,
2949 'processed_count' => count($processed_ids)
2950 );
2951
2952 wp_send_json_success($response);
2953 exit;
2954 }
2955
2956
2957 /**
2958 * This function handles various WooCommerce URL formats and permalink structures
2959 */
2960 private function mxchat_url_to_post_id_improved($url) {
2961 // First try the standard WordPress function
2962 $post_id = url_to_postid($url);
2963
2964 if ($post_id > 0) {
2965 return $post_id;
2966 }
2967
2968 // If that fails, try more aggressive URL matching
2969 // Remove trailing slashes and query parameters for better matching
2970 $clean_url = rtrim($url, '/');
2971 $clean_url = strtok($clean_url, '?'); // Remove query parameters
2972
2973 // Try again with cleaned URL
2974 $post_id = url_to_postid($clean_url);
2975 if ($post_id > 0) {
2976 return $post_id;
2977 }
2978
2979 // For bbPress forum topics, try extracting slug from URL
2980 if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
2981 // Handle bbPress URLs: /forums/topic/topic-name/
2982 if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
2983 $topic_slug = $matches[1];
2984
2985 // Look up topic by slug
2986 $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
2987 if ($topic) {
2988 return $topic->ID;
2989 }
2990
2991 // Alternative method: query by post_name
2992 global $wpdb;
2993 $post_id = $wpdb->get_var($wpdb->prepare(
2994 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
2995 $topic_slug
2996 ));
2997
2998 if ($post_id) {
2999 return intval($post_id);
3000 }
3001 }
3002
3003 // Handle simpler topic URLs: /topic/topic-name/
3004 if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
3005 $topic_slug = $matches[1];
3006
3007 global $wpdb;
3008 $post_id = $wpdb->get_var($wpdb->prepare(
3009 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3010 $topic_slug
3011 ));
3012
3013 if ($post_id) {
3014 return intval($post_id);
3015 }
3016 }
3017 }
3018
3019 // For WooCommerce products
3020 if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
3021 // Extract product slug from various URL formats
3022 $product_slug = '';
3023
3024 // Handle pretty permalinks: /product/product-name/
3025 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
3026 $product_slug = $matches[1];
3027 }
3028 // Handle query parameters: ?product=product-name
3029 elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
3030 $product_slug = $matches[1];
3031 }
3032
3033 if (!empty($product_slug)) {
3034 // Look up product by slug
3035 $product = get_page_by_path($product_slug, OBJECT, 'product');
3036 if ($product) {
3037 return $product->ID;
3038 }
3039
3040 // Alternative method: query by post_name
3041 global $wpdb;
3042 $post_id = $wpdb->get_var($wpdb->prepare(
3043 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
3044 $product_slug
3045 ));
3046
3047 if ($post_id) {
3048 return intval($post_id);
3049 }
3050 }
3051 }
3052
3053 // Generic approach: try to extract slug and match against all post types
3054 $parsed_url = wp_parse_url($clean_url);
3055 $path = $parsed_url['path'] ?? '';
3056
3057 if (!empty($path)) {
3058 // Get the last part of the path as potential slug
3059 $path_parts = array_filter(explode('/', trim($path, '/')));
3060 $potential_slug = end($path_parts);
3061
3062 if (!empty($potential_slug)) {
3063 global $wpdb;
3064
3065 // Try to find any post with this slug
3066 $post_id = $wpdb->get_var($wpdb->prepare(
3067 "SELECT ID FROM {$wpdb->posts}
3068 WHERE post_name = %s
3069 AND post_status IN ('publish', 'closed', 'private')
3070 AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
3071 ORDER BY CASE
3072 WHEN post_type = 'post' THEN 1
3073 WHEN post_type = 'page' THEN 2
3074 WHEN post_type = 'topic' THEN 3
3075 WHEN post_type = 'product' THEN 4
3076 ELSE 5
3077 END
3078 LIMIT 1",
3079 $potential_slug
3080 ));
3081
3082 if ($post_id) {
3083 return intval($post_id);
3084 }
3085 }
3086 }
3087
3088 // ADDITIONAL: Try direct database lookup by URL variations
3089 global $wpdb;
3090 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3091
3092 // Try variations of the URL (with/without trailing slash, http/https)
3093 $url_variations = array(
3094 $url,
3095 rtrim($url, '/'),
3096 $url . '/',
3097 str_replace('http://', 'https://', $url),
3098 str_replace('https://', 'http://', $url),
3099 str_replace('http://', 'https://', rtrim($url, '/')),
3100 str_replace('https://', 'http://', rtrim($url, '/'))
3101 );
3102
3103 // Remove duplicates
3104 $url_variations = array_unique($url_variations);
3105
3106 foreach ($url_variations as $variation) {
3107 $existing_record = $wpdb->get_row($wpdb->prepare(
3108 "SELECT id, source_url FROM $table_name WHERE source_url = %s",
3109 $variation
3110 ));
3111
3112 if ($existing_record) {
3113 // Try to get post ID from this stored URL
3114 $stored_post_id = url_to_postid($existing_record->source_url);
3115 if ($stored_post_id > 0) {
3116 return $stored_post_id;
3117 }
3118 }
3119 }
3120
3121 return 0; // No match found
3122 }
3123 /**
3124 * Process selected content via AJAX
3125 */
3126 public function ajax_mxchat_process_selected_content() {
3127 // Basic request validation
3128 if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
3129 wp_send_json_error('Invalid nonce');
3130 exit;
3131 }
3132
3133 if (!current_user_can('manage_options')) {
3134 wp_send_json_error('Unauthorized access');
3135 exit;
3136 }
3137
3138 // Get post IDs - safely parse the array
3139 $post_ids = array();
3140 if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
3141 foreach ($_POST['post_ids'] as $id) {
3142 $post_ids[] = absint($id);
3143 }
3144 }
3145
3146 if (empty($post_ids)) {
3147 wp_send_json_error('No content selected');
3148 exit;
3149 }
3150
3151 // Get bot_id from request
3152 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3153
3154 // Process only ONE post at a time to avoid request size issues
3155 $post_id = reset($post_ids);
3156 $post = get_post($post_id);
3157
3158 if (!$post) {
3159 wp_send_json_error('Post not found');
3160 exit;
3161 }
3162
3163 // Allow developers to modify post data before processing into knowledge base
3164 $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
3165
3166 // Get content including title, short description (for WooCommerce), and main content
3167 $content = $post->post_title . "\n\n";
3168
3169 // Add short description if it exists (WooCommerce products use post_excerpt for short description)
3170 if (!empty($post->post_excerpt)) {
3171 // Remove shortcode tags but preserve content inside them
3172 $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
3173 $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
3174 }
3175
3176 // Add main content - remove shortcode tags but preserve content inside them
3177 $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
3178 $content .= wp_strip_all_tags($clean_content);
3179
3180 // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
3181 if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
3182 $product = wc_get_product($post_id);
3183
3184 if ($product) {
3185 // Get pricing information
3186 $regular_price = $product->get_regular_price();
3187 $sale_price = $product->get_sale_price();
3188 $price = $product->get_price();
3189 $sku = $product->get_sku();
3190
3191 // Get currency symbol
3192 $currency_symbol = get_woocommerce_currency_symbol();
3193
3194 // Add pricing information
3195 $content .= "\n";
3196 if (!empty($regular_price)) {
3197 $content .= "Price: " . $currency_symbol . $regular_price . "\n";
3198 } elseif (!empty($price)) {
3199 $content .= "Price: " . $currency_symbol . $price . "\n";
3200 }
3201
3202 if (!empty($sale_price) && $sale_price !== $regular_price) {
3203 $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
3204 }
3205
3206 // Handle variable products - show price range
3207 if ($product->is_type('variable')) {
3208 $min_price = $product->get_variation_price('min');
3209 $max_price = $product->get_variation_price('max');
3210 if ($min_price !== $max_price) {
3211 $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
3212 }
3213 }
3214
3215 if (!empty($sku)) {
3216 $content .= "SKU: " . $sku . "\n";
3217 }
3218
3219 // Get product categories
3220 $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
3221 if (!empty($categories) && !is_wp_error($categories)) {
3222 $content .= "Categories: " . implode(', ', $categories) . "\n";
3223 }
3224 }
3225
3226 // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
3227 $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
3228 if (!empty($custom_tabs) && is_array($custom_tabs)) {
3229 foreach ($custom_tabs as $tab) {
3230 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3231 $tab_content = isset($tab['content']) ? $tab['content'] : '';
3232
3233 if (!empty($tab_title) && !empty($tab_content)) {
3234 $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3235 }
3236 }
3237 }
3238
3239 // Also check for reusable/saved tabs applied to this product
3240 $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
3241 if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
3242 $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
3243 if (!empty($saved_tabs) && is_array($saved_tabs)) {
3244 foreach ($applied_saved_tabs as $saved_tab_id) {
3245 if (isset($saved_tabs[$saved_tab_id])) {
3246 $tab = $saved_tabs[$saved_tab_id];
3247 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3248 $tab_content = isset($tab['content']) ? $tab['content'] : '';
3249
3250 if (!empty($tab_title) && !empty($tab_content)) {
3251 $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3252 }
3253 }
3254 }
3255 }
3256 }
3257 }
3258
3259 // ADD ACF FIELDS SUPPORT
3260 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3261 if (!empty($acf_fields)) {
3262 $acf_content_parts = array();
3263
3264 foreach ($acf_fields as $field_name => $field_value) {
3265 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3266
3267 if (!empty($formatted_value)) {
3268 $field_label = ucwords(str_replace('_', ' ', $field_name));
3269 $acf_content_parts[] = $field_label . ": " . $formatted_value;
3270 }
3271 }
3272
3273 if (!empty($acf_content_parts)) {
3274 $content .= "\n\n" . implode("\n", $acf_content_parts);
3275 }
3276 }
3277
3278 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3279 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
3280 if (!empty($custom_meta)) {
3281 $meta_content_parts = array();
3282
3283 foreach ($custom_meta as $meta_key => $meta_value) {
3284 // Convert meta key to readable label
3285 $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
3286 $meta_content_parts[] = $meta_label . ": " . $meta_value;
3287 }
3288
3289 if (!empty($meta_content_parts)) {
3290 $content .= "\n\n" . implode("\n", $meta_content_parts);
3291 }
3292 }
3293
3294 // Debug logging for WordPress Import content
3295 error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
3296 error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
3297 error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
3298 error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
3299
3300 // Note: Removed 10,000 char limit - chunking now handles large content properly
3301
3302 // Get bot-specific API key
3303 $bot_options = $this->get_bot_options($bot_id);
3304 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3305 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3306
3307 if (strpos($selected_model, 'voyage') === 0) {
3308 $api_key = $options['voyage_api_key'] ?? '';
3309 $provider_name = 'Voyage AI';
3310 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3311 $api_key = $options['gemini_api_key'] ?? '';
3312 $provider_name = 'Google Gemini';
3313 } else {
3314 $api_key = $options['api_key'] ?? '';
3315 $provider_name = 'OpenAI';
3316 }
3317
3318 if (empty($api_key)) {
3319 MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing');
3320 wp_send_json_error($provider_name . ' API key not configured');
3321 exit;
3322 }
3323
3324 $source_url = get_permalink($post_id);
3325 $vector_id = md5($source_url); // Vector ID for Pinecone
3326
3327 // Check for existing content in bot-specific storage
3328 $is_update = false;
3329
3330 // Get bot-specific Pinecone configuration
3331 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
3332 $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
3333
3334 if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
3335 // Check Pinecone for this bot
3336 $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
3337 if (isset($pinecone_data[$post_id])) {
3338 $is_update = true;
3339 }
3340 } else {
3341 // Check WordPress DB (same as before since it's shared)
3342 global $wpdb;
3343 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3344 $existing_record = $wpdb->get_row($wpdb->prepare(
3345 "SELECT id FROM $table_name WHERE source_url = %s",
3346 $source_url
3347 ));
3348
3349 if ($existing_record) {
3350 $is_update = true;
3351 }
3352 }
3353
3354 // UPDATED 2.5.6: Determine content type based on post_type
3355 $post_type = $post->post_type;
3356 $content_type = 'content'; // Default fallback
3357
3358 // Map WordPress post types to content types
3359 switch ($post_type) {
3360 case 'post':
3361 $content_type = 'post';
3362 break;
3363 case 'page':
3364 $content_type = 'page';
3365 break;
3366 case 'product':
3367 $content_type = 'product';
3368 break;
3369 default:
3370 // For custom post types, use the post type name
3371 $content_type = sanitize_key($post_type);
3372 break;
3373 }
3374
3375 // Use the centralized utility function with bot_id and content_type
3376 $result = MxChat_Utils::submit_content_to_db(
3377 $content,
3378 $source_url,
3379 $api_key,
3380 $vector_id,
3381 $bot_id,
3382 $content_type
3383 );
3384
3385 if (is_wp_error($result)) {
3386 MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
3387 wp_send_json_error('Storage failed: ' . $result->get_error_message());
3388 exit;
3389 }
3390
3391 // Automatically apply role restriction based on tags
3392 $this->apply_role_restriction_to_post($post_id, $source_url);
3393
3394 $operation_type = $is_update ? 'update' : 'new';
3395
3396 // Count ACF fields for debugging
3397 $acf_field_count = count($acf_fields);
3398
3399 // Success response with minimal data
3400 wp_send_json_success(array(
3401 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
3402 'post_id' => $post_id,
3403 'title' => $post->post_title,
3404 'operation_type' => $operation_type,
3405 'vector_id' => $vector_id,
3406 'acf_fields_found' => $acf_field_count,
3407 'content_preview' => substr($content, 0, 100) . '...',
3408 'bot_id' => $bot_id
3409 ));
3410 exit;
3411 }
3412
3413 private function apply_role_restriction_to_post($post_id, $source_url) {
3414 // Get tag-role mappings
3415 $mappings = get_option('mxchat_tag_role_mappings', array());
3416
3417 if (empty($mappings)) {
3418 return; // No mappings, leave as public
3419 }
3420
3421 // Get all tags for the post
3422 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
3423
3424 if (empty($post_tags)) {
3425 return; // No tags, leave as public
3426 }
3427
3428 // Determine the highest role restriction based on tags
3429 $highest_role = 'public';
3430 $role_hierarchy = array(
3431 'public' => 0,
3432 'logged_in' => 1,
3433 'subscriber' => 2,
3434 'contributor' => 3,
3435 'author' => 4,
3436 'editor' => 5,
3437 'administrator' => 6
3438 );
3439
3440 foreach ($post_tags as $tag_slug) {
3441 if (isset($mappings[$tag_slug])) {
3442 $role = $mappings[$tag_slug];
3443 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
3444 $highest_role = $role;
3445 }
3446 }
3447 }
3448
3449 // If no restricted tags found, return (leave as public)
3450 if ($highest_role === 'public') {
3451 return;
3452 }
3453
3454 // Update the role restriction in the database
3455 global $wpdb;
3456
3457 // Check if using Pinecone
3458 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3459 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3460
3461 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3462 // Update Pinecone role restriction
3463 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
3464 $vector_id = md5($source_url);
3465
3466 $wpdb->replace(
3467 $roles_table,
3468 array(
3469 'vector_id' => $vector_id,
3470 'role_restriction' => $highest_role,
3471 'updated_at' => current_time('mysql')
3472 ),
3473 array('%s', '%s', '%s')
3474 );
3475 } else {
3476 // Update WordPress DB
3477 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3478
3479 $wpdb->update(
3480 $table_name,
3481 array('role_restriction' => $highest_role),
3482 array('source_url' => $source_url),
3483 array('%s'),
3484 array('%s')
3485 );
3486 }
3487 }
3488
3489 public function mxchat_get_public_post_types() {
3490 // Get all public post types
3491 $post_types = get_post_types(array('public' => true), 'objects');
3492 $post_type_options = array();
3493
3494 foreach ($post_types as $post_type) {
3495 $post_type_options[$post_type->name] = $post_type->label;
3496 }
3497
3498 // Also include common forum/community post types that might not be marked as public
3499 $additional_types = array(
3500 'topic' => 'Forum Topics (bbPress)',
3501 'reply' => 'Forum Replies (bbPress)',
3502 'forum' => 'Forums (bbPress)',
3503 'wpforo_topic' => 'wpForo Topics',
3504 'wpforo_post' => 'wpForo Posts'
3505 );
3506
3507 foreach ($additional_types as $type_name => $type_label) {
3508 if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
3509 $post_type_options[$type_name] = $type_label;
3510 }
3511 }
3512
3513 return $post_type_options;
3514 }
3515
3516 /**
3517 * Retrieves processed content from Pinecone API
3518 */
3519 public function mxchat_get_pinecone_processed_content($pinecone_options) {
3520 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3521 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3522
3523 if (empty($api_key) || empty($host)) {
3524 return array();
3525 }
3526
3527 $pinecone_data = array();
3528
3529 try {
3530 // Always get fresh data from Pinecone
3531 $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
3532
3533 // Method 2: Final fallback - try stats endpoint (if available)
3534 if (empty($pinecone_data)) {
3535 $stats_url = "https://{$host}/describe_index_stats";
3536
3537 $response = wp_remote_post($stats_url, array(
3538 'headers' => array(
3539 'Api-Key' => $api_key,
3540 'Content-Type' => 'application/json'
3541 ),
3542 'body' => json_encode(array()),
3543 'timeout' => 30
3544 ));
3545
3546 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3547 $body = wp_remote_retrieve_body($response);
3548 $stats_data = json_decode($body, true);
3549 }
3550 }
3551
3552 } catch (Exception $e) {
3553 // Log error but return fresh data only
3554 }
3555
3556 return $pinecone_data;
3557 }
3558 public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
3559 //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
3560
3561 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3562 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3563
3564 if (empty($api_key) || empty($host) || empty($vector_ids)) {
3565 //error_log('DEBUG: Missing parameters for fetch by IDs');
3566 return array();
3567 }
3568
3569 try {
3570 $fetch_url = "https://{$host}/vectors/fetch";
3571 //error_log('DEBUG: Fetch URL: ' . $fetch_url);
3572 //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
3573
3574 // Pinecone fetch API allows fetching specific vectors by ID
3575 $fetch_data = array(
3576 'ids' => array_values($vector_ids)
3577 );
3578
3579 $response = wp_remote_post($fetch_url, array(
3580 'headers' => array(
3581 'Api-Key' => $api_key,
3582 'Content-Type' => 'application/json'
3583 ),
3584 'body' => json_encode($fetch_data),
3585 'timeout' => 30
3586 ));
3587
3588 if (is_wp_error($response)) {
3589 //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
3590 return array();
3591 }
3592
3593 $response_code = wp_remote_retrieve_response_code($response);
3594 //error_log('DEBUG: Fetch response code: ' . $response_code);
3595
3596 if ($response_code !== 200) {
3597 $error_body = wp_remote_retrieve_body($response);
3598 //error_log('DEBUG: Fetch failed with body: ' . $error_body);
3599 return array();
3600 }
3601
3602 $body = wp_remote_retrieve_body($response);
3603 $data = json_decode($body, true);
3604
3605 //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
3606
3607 if (!isset($data['vectors'])) {
3608 //error_log('DEBUG: No vectors key in response');
3609 return array();
3610 }
3611
3612 $processed_data = array();
3613
3614 foreach ($data['vectors'] as $vector_id => $vector_data) {
3615 $metadata = $vector_data['metadata'] ?? array();
3616 $source_url = $metadata['source_url'] ?? '';
3617
3618 if (!empty($source_url)) {
3619 $post_id = url_to_postid($source_url);
3620 if ($post_id) {
3621 $created_at = $metadata['created_at'] ?? '';
3622 $processed_date = 'Recently';
3623
3624 if (!empty($created_at)) {
3625 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
3626 if ($timestamp) {
3627 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3628 }
3629 }
3630
3631 $processed_data[$post_id] = array(
3632 'db_id' => $vector_id,
3633 'processed_date' => $processed_date,
3634 'url' => $source_url,
3635 'source' => 'pinecone',
3636 'timestamp' => $timestamp ?? current_time('timestamp')
3637 );
3638 }
3639 }
3640 }
3641
3642 //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
3643 return $processed_data;
3644
3645 } catch (Exception $e) {
3646 //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
3647 return array();
3648 }
3649 }
3650
3651 /**
3652 * Get embedding dimensions based on the selected model.
3653 */
3654 private function mxchat_get_embedding_dimensions() {
3655 $options = get_option('mxchat_options', array());
3656 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3657
3658 $model_dimensions = array(
3659 'text-embedding-ada-002' => 1536,
3660 'text-embedding-3-small' => 1536,
3661 'text-embedding-3-large' => 3072,
3662 'voyage-2' => 1024,
3663 'voyage-large-2' => 1536,
3664 'voyage-3-large' => 2048,
3665 'gemini-embedding-001' => 1536,
3666 );
3667
3668 if (strpos($selected_model, 'voyage-3-large') === 0) {
3669 $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
3670 return intval($custom_dimensions);
3671 }
3672
3673 if (strpos($selected_model, 'gemini-embedding') === 0) {
3674 $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
3675 return intval($custom_dimensions);
3676 }
3677
3678 return $model_dimensions[$selected_model] ?? 1536;
3679 }
3680
3681 /**
3682 * Scan Pinecone for processed content
3683 */
3684 public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
3685 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3686 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3687
3688 if (empty($api_key) || empty($host)) {
3689 return array();
3690 }
3691
3692 try {
3693 // Use multiple random vectors to get better coverage
3694 $all_matches = array();
3695 $seen_ids = array();
3696
3697 // Get correct dimensions for the configured embedding model
3698 $dimensions = $this->mxchat_get_embedding_dimensions();
3699
3700 // Try 3 different random vectors to get better coverage
3701 for ($i = 0; $i < 3; $i++) {
3702 $query_url = "https://{$host}/query";
3703
3704 // Generate a random unit vector instead of zeros
3705 $random_vector = array();
3706 for ($j = 0; $j < $dimensions; $j++) {
3707 $random_vector[] = (rand(-1000, 1000) / 1000.0);
3708 }
3709
3710 // Normalize the vector to unit length
3711 $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
3712 if ($magnitude > 0) {
3713 $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
3714 }
3715
3716 $query_data = array(
3717 'includeMetadata' => true,
3718 'includeValues' => false,
3719 'topK' => 10000,
3720 'vector' => $random_vector
3721 );
3722
3723 $response = wp_remote_post($query_url, array(
3724 'headers' => array(
3725 'Api-Key' => $api_key,
3726 'Content-Type' => 'application/json'
3727 ),
3728 'body' => json_encode($query_data),
3729 'timeout' => 30
3730 ));
3731
3732 if (is_wp_error($response)) {
3733 continue;
3734 }
3735
3736 $response_code = wp_remote_retrieve_response_code($response);
3737
3738 if ($response_code !== 200) {
3739 continue;
3740 }
3741
3742 $body = wp_remote_retrieve_body($response);
3743 $data = json_decode($body, true);
3744
3745 if (isset($data['matches'])) {
3746 foreach ($data['matches'] as $match) {
3747 $match_id = $match['id'] ?? '';
3748 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
3749 $all_matches[] = $match;
3750 $seen_ids[$match_id] = true;
3751 }
3752 }
3753 }
3754 }
3755
3756 // Convert matches to processed data format, grouping by URL to count chunks
3757 $processed_data = array();
3758 $url_chunk_counts = array();
3759
3760 foreach ($all_matches as $match) {
3761 $metadata = $match['metadata'] ?? array();
3762 $source_url = $metadata['source_url'] ?? '';
3763 $match_id = $match['id'] ?? '';
3764
3765 if (!empty($source_url) && !empty($match_id)) {
3766 $post_id = url_to_postid($source_url);
3767 if ($post_id) {
3768 // Count chunks per post_id
3769 if (!isset($url_chunk_counts[$post_id])) {
3770 $url_chunk_counts[$post_id] = 0;
3771 }
3772 $url_chunk_counts[$post_id]++;
3773
3774 $created_at = $metadata['created_at'] ?? '';
3775 $processed_date = 'Recently';
3776
3777 if (!empty($created_at)) {
3778 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
3779 if ($timestamp) {
3780 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3781 }
3782 }
3783
3784 // Only store if not already set, or update with newer timestamp
3785 if (!isset($processed_data[$post_id]) ||
3786 ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
3787 $processed_data[$post_id] = array(
3788 'db_id' => $match_id,
3789 'processed_date' => $processed_date,
3790 'url' => $source_url,
3791 'source' => 'pinecone',
3792 'timestamp' => $timestamp ?? current_time('timestamp')
3793 );
3794 }
3795 }
3796 }
3797 }
3798
3799 // Add chunk counts to processed data
3800 foreach ($url_chunk_counts as $post_id => $chunk_count) {
3801 if (isset($processed_data[$post_id])) {
3802 $processed_data[$post_id]['chunk_count'] = $chunk_count;
3803 }
3804 }
3805
3806 return $processed_data;
3807
3808 } catch (Exception $e) {
3809 return array();
3810 }
3811 }
3812 /**
3813 * Generate embeddings from input text for MXChat with bot support
3814 */
3815 private function mxchat_generate_embedding($text, $bot_id = 'default') {
3816 // Enable detailed logging for debugging
3817 //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
3818 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
3819
3820 // Get bot-specific options
3821 $bot_options = $this->get_bot_options($bot_id);
3822 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3823
3824 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3825 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
3826
3827 // Determine provider and endpoint
3828 if (strpos($selected_model, 'voyage') === 0) {
3829 $api_key = $options['voyage_api_key'] ?? '';
3830 $endpoint = 'https://api.voyageai.com/v1/embeddings';
3831 $provider_name = 'Voyage AI';
3832 //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
3833 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3834 $api_key = $options['gemini_api_key'] ?? '';
3835 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
3836 $provider_name = 'Google Gemini';
3837 //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
3838 } else {
3839 $api_key = $options['api_key'] ?? '';
3840 $endpoint = 'https://api.openai.com/v1/embeddings';
3841 $provider_name = 'OpenAI';
3842 //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
3843 }
3844
3845 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
3846
3847 if (empty($api_key)) {
3848 $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
3849 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
3850 return $error_message;
3851 }
3852
3853 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
3854 $estimated_tokens = ceil(str_word_count($text) / 0.75);
3855 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
3856
3857 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
3858 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
3859 // Consider truncating text here
3860 }
3861
3862 // Prepare request body based on provider
3863 if (strpos($selected_model, 'gemini-embedding') === 0) {
3864 // Gemini API format
3865 $request_body = array(
3866 'model' => 'models/' . $selected_model,
3867 'content' => array(
3868 'parts' => array(
3869 array('text' => $text)
3870 )
3871 )
3872 );
3873
3874 // Set output dimensionality to 1536 for consistency with other models
3875 $request_body['outputDimensionality'] = 1536;
3876 } else {
3877 // OpenAI/Voyage API format
3878 $request_body = array(
3879 'model' => $selected_model,
3880 'input' => $text
3881 );
3882
3883 // Add output_dimension for voyage-3-large model
3884 if ($selected_model === 'voyage-3-large') {
3885 $request_body['output_dimension'] = 2048;
3886 }
3887 }
3888
3889 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
3890
3891 // Prepare headers based on provider
3892 if (strpos($selected_model, 'gemini-embedding') === 0) {
3893 // Gemini uses API key as query parameter
3894 $endpoint .= '?key=' . $api_key;
3895 $headers = array(
3896 'Content-Type' => 'application/json'
3897 );
3898 } else {
3899 // OpenAI/Voyage use Bearer token
3900 $headers = array(
3901 'Authorization' => 'Bearer ' . $api_key,
3902 'Content-Type' => 'application/json'
3903 );
3904 }
3905
3906 // Make API request
3907 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
3908 $response = wp_remote_post($endpoint, array(
3909 'body' => wp_json_encode($request_body),
3910 'headers' => $headers,
3911 'timeout' => 60 // Increased timeout for large inputs
3912 ));
3913
3914 // Handle wp_remote_post errors
3915 if (is_wp_error($response)) {
3916 $error_message = $response->get_error_message();
3917 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
3918 return 'Connection error: ' . $error_message;
3919 }
3920
3921 // Get and check HTTP response code
3922 $http_code = wp_remote_retrieve_response_code($response);
3923 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
3924
3925 if ($http_code !== 200) {
3926 $error_body = wp_remote_retrieve_body($response);
3927 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
3928
3929 // Try to parse error for more details
3930 $error_json = json_decode($error_body, true);
3931 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
3932 $error_type = $error_json['error']['type'] ?? 'unknown';
3933 $error_message = $error_json['error']['message'] ?? 'No message';
3934 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
3935 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
3936
3937 // Customize error message for common API errors
3938 if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
3939 $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
3940 } elseif ($error_type === 'authentication_error') {
3941 $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
3942 }
3943
3944 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
3945 return $error_message;
3946 }
3947
3948 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
3949 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
3950 return $error_message;
3951 }
3952
3953 // Parse response body
3954 $response_body = wp_remote_retrieve_body($response);
3955 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
3956
3957 $response_data = json_decode($response_body, true);
3958
3959 if (json_last_error() !== JSON_ERROR_NONE) {
3960 $error = json_last_error_msg();
3961 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
3962 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
3963 return "Failed to parse API response: $error";
3964 }
3965
3966 // Handle different response formats based on provider
3967 if (strpos($selected_model, 'gemini-embedding') === 0) {
3968 // Gemini API response format
3969 if (isset($response_data['embedding']['values'])) {
3970 $embedding_dimensions = count($response_data['embedding']['values']);
3971 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
3972
3973 // Check if embedding dimensions are as expected (should be 1536)
3974 if ($embedding_dimensions !== 1536) {
3975 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
3976 }
3977
3978 return $response_data['embedding']['values'];
3979 } else {
3980 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
3981 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
3982
3983 if (isset($response_data['error'])) {
3984 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
3985 //error_log('[MXCHAT-EMBED] ' . $error_message);
3986 return $error_message;
3987 }
3988
3989 $error_message = "Invalid Gemini API response format: No embedding found";
3990 //error_log('[MXCHAT-EMBED] ' . $error_message);
3991 return $error_message;
3992 }
3993 } else {
3994 // OpenAI/Voyage API response format
3995 if (isset($response_data['data'][0]['embedding'])) {
3996 $embedding_dimensions = count($response_data['data'][0]['embedding']);
3997 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
3998
3999 // Check if embedding dimensions are as expected
4000 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
4001 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
4002 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
4003 }
4004
4005 return $response_data['data'][0]['embedding'];
4006 } else {
4007 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
4008 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4009
4010 if (isset($response_data['error'])) {
4011 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
4012 //error_log('[MXCHAT-EMBED] ' . $error_message);
4013 return $error_message;
4014 }
4015
4016 $error_message = "Invalid API response format: No embedding found";
4017 //error_log('[MXCHAT-EMBED] ' . $error_message);
4018 return $error_message;
4019 }
4020 }
4021 }
4022
4023 /**
4024 * Get bot-specific options for multi-bot functionality
4025 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
4026 */
4027 private function get_bot_options($bot_id = 'default') {
4028 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
4029
4030 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4031 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
4032 return array();
4033 }
4034
4035 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
4036
4037 if (!empty($bot_options)) {
4038 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
4039 if (isset($bot_options['similarity_threshold'])) {
4040 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
4041 }
4042 }
4043
4044 return is_array($bot_options) ? $bot_options : array();
4045 }
4046
4047 /**
4048 * Get bot-specific Pinecone configuration
4049 * Used in the knowledge retrieval functions
4050 */
4051 // Also add debugging to your get_bot_pinecone_config function
4052 private function get_bot_pinecone_config($bot_id = 'default') {
4053 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4054
4055 // If default bot or multi-bot add-on not active, use default Pinecone config
4056 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4057 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4058 $addon_options = get_option('mxchat_pinecone_addon_options', array());
4059 $config = array(
4060 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4061 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4062 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4063 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4064 );
4065 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4066 return $config;
4067 }
4068
4069 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4070
4071 // Hook for multi-bot add-on to provide bot-specific Pinecone config
4072 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4073
4074 if (!empty($bot_pinecone_config)) {
4075 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4076 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4077 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4078 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4079 } else {
4080 //error_log("MXCHAT DEBUG: Filter returned empty config!");
4081 }
4082
4083 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4084 }
4085
4086
4087 public function mxchat_ajax_dismiss_completed_status() {
4088 try {
4089 // Verify the request
4090 check_ajax_referer('mxchat_status_nonce', 'nonce');
4091
4092 if (!current_user_can('manage_options')) {
4093 wp_send_json_error('Unauthorized access');
4094 exit;
4095 }
4096
4097 $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
4098
4099 if ($card_type === 'pdf') {
4100 // Clear PDF status
4101 $pdf_url = get_transient('mxchat_last_pdf_url');
4102 if ($pdf_url) {
4103 delete_transient('mxchat_pdf_status_' . md5($pdf_url));
4104 delete_transient('mxchat_last_pdf_url');
4105 }
4106 } elseif ($card_type === 'sitemap') {
4107 // Clear sitemap status
4108 $sitemap_url = get_transient('mxchat_last_sitemap_url');
4109 if ($sitemap_url) {
4110 delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
4111 delete_transient('mxchat_last_sitemap_url');
4112 }
4113 }
4114
4115 wp_send_json_success(array('message' => 'Status dismissed successfully'));
4116
4117 } catch (Exception $e) {
4118 wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
4119 }
4120 }
4121
4122 /**
4123 * Render completed status cards on page load
4124 * This ensures completed processing status persists through page refreshes
4125 */
4126 public function mxchat_render_completed_status_cards() {
4127 $output = '';
4128
4129 // Check for completed PDF status
4130 $pdf_url = get_transient('mxchat_last_pdf_url');
4131 if ($pdf_url) {
4132 $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
4133 if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
4134 $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
4135 }
4136 }
4137
4138 // Check for completed sitemap status
4139 $sitemap_url = get_transient('mxchat_last_sitemap_url');
4140 if ($sitemap_url) {
4141 $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
4142 if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
4143 $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
4144 }
4145 }
4146
4147 return $output;
4148 }
4149
4150 /**
4151 * Render PDF status card HTML
4152 */
4153 private function mxchat_render_pdf_status_card($status, $pdf_url) {
4154 $html = '<div class="mxchat-status-card" data-card-type="pdf">';
4155 $html .= '<div class="mxchat-status-header">';
4156 $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
4157
4158 // Add dismiss button for completed status
4159 if ($status['status'] === 'complete' || $status['status'] === 'error') {
4160 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4161 }
4162
4163 // Process Batch button for processing status
4164 if ($status['status'] === 'processing') {
4165 $html .= '<button type="button" class="mxchat-manual-batch-btn"
4166 data-process-type="pdf"
4167 data-url="' . esc_attr($pdf_url) . '">
4168 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4169 }
4170
4171 // Add status badges
4172 if ($status['status'] === 'error') {
4173 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4174 } elseif ($status['status'] === 'complete') {
4175 if ($status['failed_pages'] > 0) {
4176 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4177 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
4178 } else {
4179 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4180 }
4181 }
4182
4183 $html .= '</div>'; // End header
4184
4185 // Progress bar
4186 $html .= '<div class="mxchat-progress-bar">';
4187 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4188 $html .= '</div>';
4189
4190 // Status details
4191 $html .= '<div class="mxchat-status-details">';
4192 $html .= '<p>' . sprintf(
4193 esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
4194 $status['processed_pages'],
4195 $status['total_pages'],
4196 $status['percentage']
4197 ) . '</p>';
4198
4199 // Show failed pages count if any
4200 if ($status['failed_pages'] > 0) {
4201 $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
4202 }
4203
4204 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4205 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4206
4207 // Add completion summary if available AND it's an array
4208 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4209 $summary = $status['completion_summary'];
4210 $html .= '<div class="mxchat-completion-summary">';
4211 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4212 $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
4213 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
4214 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
4215 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4216 $html .= '</div>';
4217 }
4218
4219 // Add failed pages list if any AND it's an array
4220 if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
4221 $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
4222 }
4223
4224 // Add error message if any
4225 if (isset($status['error']) && !empty($status['error'])) {
4226 $html .= '<div class="mxchat-error-notice">';
4227 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4228 $html .= '</div>';
4229 }
4230
4231 $html .= '</div>'; // End details
4232 $html .= '</div>'; // End card
4233
4234 return $html;
4235 }
4236 /**
4237 * Render sitemap status card HTML
4238 */
4239 private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
4240 $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
4241 $html .= '<div class="mxchat-status-header">';
4242 $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
4243
4244 // Add dismiss button for completed status
4245 if ($status['status'] === 'complete' || $status['status'] === 'error') {
4246 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4247 }
4248
4249 // Process Batch button for processing status
4250 if ($status['status'] === 'processing') {
4251 $html .= '<button type="button" class="mxchat-manual-batch-btn"
4252 data-process-type="sitemap"
4253 data-url="' . esc_attr($sitemap_url) . '">
4254 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4255 }
4256
4257 // Add status badges
4258 if ($status['status'] === 'error') {
4259 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4260 } elseif ($status['status'] === 'complete') {
4261 if ($status['failed_urls'] > 0) {
4262 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4263 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
4264 } else {
4265 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4266 }
4267 }
4268
4269 $html .= '</div>'; // End header
4270
4271 // Progress bar
4272 $html .= '<div class="mxchat-progress-bar">';
4273 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4274 $html .= '</div>';
4275
4276 // Status details
4277 $html .= '<div class="mxchat-status-details">';
4278 $html .= '<p>' . sprintf(
4279 esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
4280 $status['processed_urls'],
4281 $status['total_urls'],
4282 $status['percentage']
4283 ) . '</p>';
4284
4285 // Show failed URLs count if any
4286 if ($status['failed_urls'] > 0) {
4287 $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
4288 }
4289
4290 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4291 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4292
4293 // Add completion summary if available AND it's an array
4294 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4295 $summary = $status['completion_summary'];
4296 $html .= '<div class="mxchat-completion-summary">';
4297 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4298 $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
4299 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
4300 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
4301 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4302 $html .= '</div>';
4303 }
4304
4305 // Add error messages if any (but not the failed URLs list)
4306 if (!empty($status['error']) || !empty($status['last_error'])) {
4307 $html .= '<div class="mxchat-error-notice">';
4308
4309 if (!empty($status['error'])) {
4310 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4311 }
4312
4313 if (!empty($status['last_error'])) {
4314 $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
4315 }
4316
4317 $html .= '</div>';
4318 }
4319
4320 $html .= '</div>'; // End details
4321 $html .= '</div>'; // End card
4322
4323 return $html;
4324 }
4325
4326
4327 /**
4328 * Render failed pages list
4329 */
4330 private function mxchat_render_failed_pages_list($failed_pages_list) {
4331 // Validate that $failed_pages_list is an array and not empty
4332 if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
4333 return '';
4334 }
4335
4336 $html = '<div class="mxchat-error-notice">';
4337 $html .= '<div class="mxchat-failed-pages-container">';
4338 $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
4339 $html .= '<details>';
4340 $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
4341 $html .= '<div class="mxchat-failed-pages-list">';
4342
4343 // Create table for failed pages
4344 $html .= '<table class="widefat striped">';
4345 $html .= '<thead><tr>';
4346 $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
4347 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4348 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4349 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4350 $html .= '</tr></thead><tbody>';
4351
4352 // Sort failed pages by most recent
4353 $sorted_failed_pages = $failed_pages_list;
4354 usort($sorted_failed_pages, function($a, $b) {
4355 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4356 });
4357
4358 foreach ($sorted_failed_pages as $item) {
4359 // Ensure $item is an array before accessing its elements
4360 if (!is_array($item)) {
4361 continue;
4362 }
4363
4364 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4365 $html .= '<tr>';
4366 $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
4367 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4368 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4369 $html .= '<td>' . esc_html($time_ago) . '</td>';
4370 $html .= '</tr>';
4371 }
4372
4373 $html .= '</tbody></table>';
4374 $html .= '</div></details></div></div>';
4375
4376 return $html;
4377 }
4378
4379 /**
4380 * Render failed URLs list
4381 */
4382 private function mxchat_render_failed_urls_list($failed_urls_list) {
4383 // Validate that $failed_urls_list is an array and not empty
4384 if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
4385 return '';
4386 }
4387
4388 $html = '<div class="mxchat-failed-urls-container">';
4389 $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
4390 $html .= '<details>';
4391 $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
4392 $html .= '<div class="mxchat-failed-urls-list">';
4393
4394 // Create table for failed URLs
4395 $html .= '<table class="widefat striped">';
4396 $html .= '<thead><tr>';
4397 $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
4398 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4399 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4400 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4401 $html .= '</tr></thead><tbody>';
4402
4403 // Sort failed URLs by most recent
4404 $sorted_failed_urls = $failed_urls_list;
4405 usort($sorted_failed_urls, function($a, $b) {
4406 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4407 });
4408
4409 // Show up to 50 failed URLs
4410 $display_urls = array_slice($sorted_failed_urls, 0, 50);
4411
4412 foreach ($display_urls as $item) {
4413 // Ensure $item is an array before accessing its elements
4414 if (!is_array($item)) {
4415 continue;
4416 }
4417
4418 $url = $item['url'] ?? '';
4419 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4420
4421 // Truncate URL for display
4422 $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
4423
4424 $html .= '<tr>';
4425 $html .= '<td style="word-break: break-all;">';
4426 if (!empty($url)) {
4427 $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
4428 } else {
4429 $html .= esc_html__('Unknown URL', 'mxchat');
4430 }
4431 $html .= '</td>';
4432 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4433 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4434 $html .= '<td>' . esc_html($time_ago) . '</td>';
4435 $html .= '</tr>';
4436 }
4437
4438 $html .= '</tbody></table>';
4439
4440 if (count($failed_urls_list) > 50) {
4441 $html .= '<div class="mxchat-failed-urls-more">+ ' .
4442 (count($failed_urls_list) - 50) .
4443 ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
4444 }
4445
4446 $html .= '</div></details></div>';
4447
4448 return $html;
4449 }
4450
4451 /**
4452 * Get all ACF fields for a specific post, excluding any fields the user has disabled
4453 */
4454 public function mxchat_get_acf_fields_for_post($post_id) {
4455 if (!function_exists('get_fields')) {
4456 return array();
4457 }
4458
4459 $fields = get_fields($post_id);
4460 if (!$fields || !is_array($fields)) {
4461 return array();
4462 }
4463
4464 // Get excluded fields from settings
4465 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
4466 if (!empty($excluded_fields) && is_array($excluded_fields)) {
4467 foreach ($excluded_fields as $excluded_field) {
4468 if (isset($fields[$excluded_field])) {
4469 unset($fields[$excluded_field]);
4470 }
4471 }
4472 }
4473
4474 return $fields;
4475 }
4476
4477 /**
4478 * Get all registered ACF field groups and their fields for the settings UI
4479 */
4480 public function mxchat_get_all_acf_fields() {
4481 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
4482 return array();
4483 }
4484
4485 $all_fields = array();
4486 $field_groups = acf_get_field_groups();
4487
4488 if (!empty($field_groups)) {
4489 foreach ($field_groups as $group) {
4490 $group_fields = acf_get_fields($group['key']);
4491 if (!empty($group_fields)) {
4492 $all_fields[$group['title']] = array();
4493 foreach ($group_fields as $field) {
4494 $all_fields[$group['title']][] = array(
4495 'name' => $field['name'],
4496 'label' => $field['label'],
4497 'type' => $field['type']
4498 );
4499 }
4500 }
4501 }
4502 }
4503
4504 return $all_fields;
4505 }
4506
4507 /**
4508 * Get whitelisted custom post meta for a given post
4509 * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
4510 */
4511 public function mxchat_get_whitelisted_post_meta($post_id) {
4512 $whitelist = get_option('mxchat_custom_meta_whitelist', '');
4513
4514 if (empty($whitelist)) {
4515 return array();
4516 }
4517
4518 // Parse the whitelist - one meta key per line
4519 $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
4520
4521 if (empty($meta_keys)) {
4522 return array();
4523 }
4524
4525 $result = array();
4526
4527 foreach ($meta_keys as $key) {
4528 // Skip empty keys
4529 if (empty($key)) {
4530 continue;
4531 }
4532
4533 $value = get_post_meta($post_id, $key, true);
4534
4535 // Only include non-empty string values
4536 if (!empty($value) && is_string($value)) {
4537 $result[$key] = $value;
4538 } elseif (!empty($value) && is_array($value)) {
4539 // Handle array values by joining them
4540 $flat_value = $this->mxchat_flatten_meta_array($value);
4541 if (!empty($flat_value)) {
4542 $result[$key] = $flat_value;
4543 }
4544 }
4545 }
4546
4547 return $result;
4548 }
4549
4550 /**
4551 * Flatten array meta values into a readable string
4552 */
4553 private function mxchat_flatten_meta_array($array, $depth = 0) {
4554 if ($depth > 3) {
4555 return ''; // Prevent infinite recursion
4556 }
4557
4558 $parts = array();
4559
4560 foreach ($array as $key => $value) {
4561 if (is_string($value) && !empty($value)) {
4562 $parts[] = $value;
4563 } elseif (is_array($value)) {
4564 $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
4565 if (!empty($nested)) {
4566 $parts[] = $nested;
4567 }
4568 }
4569 }
4570
4571 return implode(', ', $parts);
4572 }
4573
4574 /**
4575 * Format ACF field values for content extraction
4576 */
4577 public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
4578 if (empty($value)) {
4579 return '';
4580 }
4581
4582 // Handle WP_Post objects first (THIS IS THE KEY FIX)
4583 if ($value instanceof WP_Post) {
4584 return $value->post_title ?: '';
4585 }
4586
4587 // Handle other WP objects
4588 if (is_object($value)) {
4589 if (isset($value->post_title)) {
4590 return $value->post_title;
4591 } elseif (isset($value->display_name)) {
4592 return $value->display_name;
4593 } elseif (isset($value->name)) {
4594 return $value->name;
4595 } elseif (method_exists($value, '__toString')) {
4596 try {
4597 return (string) $value;
4598 } catch (Exception $e) {
4599 return '';
4600 }
4601 }
4602 // For any other objects, return empty string
4603 return '';
4604 }
4605
4606 // Handle different ACF field types
4607 if (is_array($value)) {
4608 // Check if it's an image/file field
4609 if (isset($value['url'])) {
4610 // Image field - return alt text, title, or caption
4611 if (!empty($value['alt'])) {
4612 return $value['alt'];
4613 } elseif (!empty($value['title'])) {
4614 return $value['title'];
4615 } elseif (!empty($value['caption'])) {
4616 return $value['caption'];
4617 } else {
4618 return ''; // Don't include just the URL
4619 }
4620 }
4621
4622 // Check if it's a post object or relationship field
4623 if (isset($value['post_title'])) {
4624 return $value['post_title'];
4625 }
4626
4627 // Check if it's a user field
4628 if (isset($value['display_name'])) {
4629 return $value['display_name'];
4630 }
4631
4632 // Check if it's a taxonomy term
4633 if (isset($value['name']) && isset($value['taxonomy'])) {
4634 return $value['name'];
4635 }
4636
4637 // Check if it's a select field with label
4638 if (isset($value['label'])) {
4639 return $value['label'];
4640 }
4641
4642 // Check for repeater field or flexible content
4643 if (is_numeric(key($value))) {
4644 $sub_values = array();
4645 foreach ($value as $sub_item) {
4646 if (is_array($sub_item)) {
4647 // For repeater/flexible content, extract text values
4648 $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
4649 if (!empty($sub_text)) {
4650 $sub_values[] = $sub_text;
4651 }
4652 } elseif ($sub_item instanceof WP_Post) {
4653 // Handle WP_Post objects in arrays
4654 $sub_values[] = $sub_item->post_title ?: '';
4655 } else {
4656 $sub_values[] = (string) $sub_item;
4657 }
4658 }
4659 return implode(', ', array_filter($sub_values));
4660 }
4661
4662 // For other arrays, try to extract meaningful text
4663 $text_values = array();
4664 foreach ($value as $key => $val) {
4665 if (is_string($val) && !empty(trim($val))) {
4666 $text_values[] = trim($val);
4667 } elseif ($val instanceof WP_Post) {
4668 // Handle WP_Post objects in associative arrays
4669 $text_values[] = $val->post_title ?: '';
4670 } elseif (is_array($val) && isset($val['post_title'])) {
4671 $text_values[] = $val['post_title'];
4672 } elseif (is_array($val) && isset($val['name'])) {
4673 $text_values[] = $val['name'];
4674 }
4675 }
4676
4677 return implode(', ', array_filter($text_values));
4678 }
4679
4680 // Handle boolean values
4681 if (is_bool($value)) {
4682 return $value ? 'Yes' : 'No';
4683 }
4684
4685 // Handle numeric values
4686 if (is_numeric($value)) {
4687 return (string) $value;
4688 }
4689
4690 // Handle string values
4691 if (is_string($value)) {
4692 return trim($value);
4693 }
4694
4695 // For anything else that we can't handle, return empty string
4696 // This prevents the "Object could not be converted to string" error
4697 return '';
4698 }
4699
4700 /**
4701 * Extract text from complex ACF array structures
4702 */
4703 private function mxchat_extract_text_from_acf_array($array) {
4704 if (!is_array($array)) {
4705 return '';
4706 }
4707
4708 $text_parts = array();
4709
4710 foreach ($array as $key => $value) {
4711 if (is_string($value) && !empty(trim($value))) {
4712 // Skip keys that are likely to be IDs or technical values
4713 if (!is_numeric($value) || strlen($value) > 10) {
4714 $text_parts[] = trim($value);
4715 }
4716 } elseif ($value instanceof WP_Post) {
4717 // Handle WP_Post objects
4718 $text_parts[] = $value->post_title ?: '';
4719 } elseif (is_array($value)) {
4720 if (isset($value['post_title'])) {
4721 $text_parts[] = $value['post_title'];
4722 } elseif (isset($value['name'])) {
4723 $text_parts[] = $value['name'];
4724 } elseif (isset($value['label'])) {
4725 $text_parts[] = $value['label'];
4726 }
4727 } elseif (is_object($value)) {
4728 // Handle other objects safely
4729 if (isset($value->post_title)) {
4730 $text_parts[] = $value->post_title;
4731 } elseif (isset($value->name)) {
4732 $text_parts[] = $value->name;
4733 } elseif (isset($value->display_name)) {
4734 $text_parts[] = $value->display_name;
4735 }
4736 }
4737 }
4738
4739 return implode(', ', array_filter($text_parts));
4740 }
4741
4742 /**
4743 * Handle ACF save - fires after ACF fields are saved
4744 * This ensures ACF field data is available when syncing to knowledge base
4745 */
4746 public function mxchat_handle_acf_save($post_id) {
4747 // Skip if not a valid post
4748 if (!$post_id || $post_id === 'options') {
4749 return;
4750 }
4751
4752 // Skip autosaves and revisions
4753 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
4754 return;
4755 }
4756
4757 $post = get_post($post_id);
4758 if (!$post) {
4759 return;
4760 }
4761
4762 $post_type = $post->post_type;
4763
4764 // Check if sync is enabled for this post type
4765 $should_sync = false;
4766
4767 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4768 $should_sync = true;
4769 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4770 $should_sync = true;
4771 } else if ($post_type === 'product' && class_exists('WooCommerce')) {
4772 // WooCommerce products - check if WooCommerce integration is enabled
4773 $options = get_option('mxchat_options', array());
4774 if (isset($options['enable_woocommerce_integration']) &&
4775 ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
4776 $should_sync = true;
4777 }
4778 } else {
4779 // Check custom post types
4780 $option_name = 'mxchat_auto_sync_' . $post_type;
4781 if (get_option($option_name) === '1') {
4782 $should_sync = true;
4783 }
4784 }
4785
4786 if (!$should_sync) {
4787 return;
4788 }
4789
4790 // Only process published posts
4791 if ($post->post_status !== 'publish') {
4792 return;
4793 }
4794
4795 // Check if this post has any ACF fields - if not, no need to re-sync
4796 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4797 if (empty($acf_fields)) {
4798 return;
4799 }
4800
4801 // Use a transient to prevent duplicate processing (post_updated may have already run)
4802 $transient_key = 'mxchat_acf_synced_' . $post_id;
4803 if (get_transient($transient_key)) {
4804 return;
4805 }
4806 set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
4807
4808 // Re-run the sync with ACF data now available
4809 // We pass $update=true since this is effectively an update with ACF data
4810 $this->mxchat_handle_post_update($post_id, $post, true);
4811 }
4812
4813 public function mxchat_handle_post_update($post_id, $post, $update) {
4814 // Basic validation checks
4815 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
4816 return;
4817 }
4818
4819 $post_type = $post->post_type;
4820
4821 // Check if sync is enabled for this post type
4822 $should_sync = false;
4823
4824 // Check built-in post types first
4825 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4826 $should_sync = true;
4827 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4828 $should_sync = true;
4829 } else {
4830 // Check custom post types
4831 $option_name = 'mxchat_auto_sync_' . $post_type;
4832 if (get_option($option_name) === '1') {
4833 $should_sync = true;
4834 }
4835 }
4836
4837 if (!$should_sync) {
4838 return;
4839 }
4840
4841 // Check if we have stored the previous status and URL in our transients
4842 $previous_status_key = 'mxchat_prev_status_' . $post_id;
4843 $previous_status = get_transient($previous_status_key);
4844
4845 $previous_url_key = 'mxchat_prev_url_' . $post_id;
4846 $previous_url = get_transient($previous_url_key);
4847
4848 // If the post was previously published but is now not published, remove from knowledge base
4849 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
4850 // Use the stored URL from when it was published, or fall back to current permalink
4851 $source_url = $previous_url ?: get_permalink($post_id);
4852
4853 if ($source_url) {
4854 // Check if Pinecone is enabled
4855 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4856 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4857
4858 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4859 // Delete from Pinecone
4860 $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
4861 } else {
4862 // Delete from WordPress DB
4863 global $wpdb;
4864 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4865
4866 $result = $wpdb->delete(
4867 $table_name,
4868 array('source_url' => $source_url),
4869 array('%s')
4870 );
4871 }
4872 }
4873
4874 // Clean up the transients and exit early
4875 delete_transient($previous_status_key);
4876 delete_transient($previous_url_key);
4877 return;
4878 }
4879
4880 // Store the current status for next time (if this is an update)
4881 if ($update) {
4882 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
4883
4884 // If the post is currently published, also store its URL
4885 if ($post->post_status === 'publish') {
4886 $current_url = get_permalink($post_id);
4887 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
4888 }
4889 }
4890
4891 // Only process currently published content for adding/updating
4892 if ($post->post_status === 'publish') {
4893 // Get the source URL
4894 $source_url = get_permalink($post_id);
4895
4896 // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
4897 $title = get_the_title($post_id);
4898 $content = get_post_field('post_content', $post_id);
4899 $excerpt = get_post_field('post_excerpt', $post_id);
4900
4901 // Remove shortcode tags but preserve content inside them
4902 $content = $this->strip_shortcode_tags_preserve_content($content);
4903 $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
4904
4905 // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
4906 $content = wp_strip_all_tags($content);
4907
4908 // Combine title, short description (if exists), and content
4909 $final_content = $title . "\n\n";
4910
4911 // Add short description if it exists (WooCommerce products use post_excerpt for short description)
4912 if (!empty($excerpt)) {
4913 $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
4914 }
4915
4916 $final_content .= $content;
4917
4918 // For WooCommerce products, include pricing and product details
4919 if ($post_type === 'product' && class_exists('WooCommerce')) {
4920 $product = wc_get_product($post_id);
4921
4922 if ($product) {
4923 // Get pricing information
4924 $regular_price = $product->get_regular_price();
4925 $sale_price = $product->get_sale_price();
4926 $price = $product->get_price();
4927 $sku = $product->get_sku();
4928
4929 // Get currency symbol
4930 $currency_symbol = get_woocommerce_currency_symbol();
4931
4932 // Add pricing information
4933 $final_content .= "\n";
4934 if (!empty($regular_price)) {
4935 $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
4936 } elseif (!empty($price)) {
4937 $final_content .= "Price: " . $currency_symbol . $price . "\n";
4938 }
4939
4940 if (!empty($sale_price) && $sale_price !== $regular_price) {
4941 $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
4942 }
4943
4944 // Handle variable products - show price range
4945 if ($product->is_type('variable')) {
4946 $min_price = $product->get_variation_price('min');
4947 $max_price = $product->get_variation_price('max');
4948 if ($min_price !== $max_price) {
4949 $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
4950 }
4951 }
4952
4953 if (!empty($sku)) {
4954 $final_content .= "SKU: " . $sku . "\n";
4955 }
4956
4957 // Get product categories
4958 $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
4959 if (!empty($categories) && !is_wp_error($categories)) {
4960 $final_content .= "Categories: " . implode(', ', $categories) . "\n";
4961 }
4962 }
4963 }
4964
4965 // For custom post types like job_listing, include additional fields
4966 if ($post_type === 'job_listing') {
4967 // Add job-specific meta if available
4968 $job_location = get_post_meta($post_id, '_job_location', true);
4969 if (!empty($job_location)) {
4970 $final_content .= "\n\nLocation: " . $job_location;
4971 }
4972
4973 // Get job type terms
4974 $job_types = get_the_terms($post_id, 'job_listing_type');
4975 if (!empty($job_types) && !is_wp_error($job_types)) {
4976 $types = array();
4977 foreach ($job_types as $type) {
4978 $types[] = $type->name;
4979 }
4980 $final_content .= "\n\nJob Type: " . implode(', ', $types);
4981 }
4982
4983 // Get company name if available
4984 $company_name = get_post_meta($post_id, '_company_name', true);
4985 if (!empty($company_name)) {
4986 $final_content .= "\n\nCompany: " . $company_name;
4987 }
4988 }
4989
4990 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
4991 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4992 if (!empty($acf_fields)) {
4993 $acf_content_parts = array();
4994
4995 foreach ($acf_fields as $field_name => $field_value) {
4996 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
4997 if (!empty($formatted_value)) {
4998 // Convert field name to readable label
4999 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5000 $acf_content_parts[] = $field_label . ": " . $formatted_value;
5001 }
5002 }
5003
5004 if (!empty($acf_content_parts)) {
5005 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5006 }
5007 }
5008
5009 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5010 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
5011 if (!empty($custom_meta)) {
5012 $meta_content_parts = array();
5013
5014 foreach ($custom_meta as $meta_key => $meta_value) {
5015 // Convert meta key to readable label
5016 $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
5017 $meta_content_parts[] = $meta_label . ": " . $meta_value;
5018 }
5019
5020 if (!empty($meta_content_parts)) {
5021 $final_content .= "\n\n" . implode("\n", $meta_content_parts);
5022 }
5023 }
5024
5025 // Get API key with proper model detection
5026 $options = get_option('mxchat_options');
5027 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5028
5029 if (strpos($selected_model, 'voyage') === 0) {
5030 $api_key = $options['voyage_api_key'] ?? '';
5031 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5032 $api_key = $options['gemini_api_key'] ?? '';
5033 } else {
5034 $api_key = $options['api_key'] ?? '';
5035 }
5036
5037 if (empty($api_key)) {
5038 return;
5039 }
5040
5041 // Use the centralized utility function for storage
5042 $result = MxChat_Utils::submit_content_to_db(
5043 $final_content,
5044 $source_url,
5045 $api_key,
5046 md5($source_url) // Vector ID for Pinecone
5047 );
5048
5049 // After successful storage, apply role restriction based on tags
5050 if (!is_wp_error($result)) {
5051 $this->apply_role_restriction_to_post($post_id, $source_url);
5052 }
5053 }
5054
5055 // Clean up the stored previous status if not used above
5056 if ($previous_status !== 'publish' || $post->post_status === 'publish') {
5057 delete_transient($previous_status_key);
5058 delete_transient($previous_url_key);
5059 }
5060 }
5061
5062 /**
5063 * Store the post status and URL before update to detect status transitions
5064 * This runs before the post is actually updated in the database
5065 */
5066 public function mxchat_store_pre_update_status($post_id, $data) {
5067 // Get the current post from database (before update)
5068 $current_post = get_post($post_id);
5069
5070 if ($current_post) {
5071 // Store the current status temporarily
5072 $status_key = 'mxchat_prev_status_' . $post_id;
5073 set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
5074
5075 // If the post is currently published, also store its URL
5076 if ($current_post->post_status === 'publish') {
5077 $url_key = 'mxchat_prev_url_' . $post_id;
5078 $current_url = get_permalink($post_id);
5079 set_transient($url_key, $current_url, HOUR_IN_SECONDS);
5080 }
5081 }
5082 }
5083
5084 public function mxchat_handle_post_delete($post_id) {
5085 // Get post data before it's deleted
5086 $post = get_post($post_id);
5087
5088 // Basic validation
5089 if (!$post || wp_is_post_revision($post_id)) {
5090 return;
5091 }
5092
5093 $post_type = $post->post_type;
5094
5095 // Check if sync is enabled for this post type
5096 $should_sync = false;
5097
5098 // Check built-in post types first
5099 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5100 $should_sync = true;
5101 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5102 $should_sync = true;
5103 } else {
5104 // Check custom post types
5105 $option_name = 'mxchat_auto_sync_' . $post_type;
5106 if (get_option($option_name) === '1') {
5107 $should_sync = true;
5108 }
5109 }
5110
5111 if (!$should_sync) {
5112 return;
5113 }
5114
5115 // Get the URL before post is deleted
5116 $source_url = get_permalink($post_id);
5117 if (!$source_url) {
5118 //error_log('MXChat: Failed to get permalink for post ' . $post_id);
5119 return;
5120 }
5121
5122 // Use chunk-aware deletion (handles both chunked and non-chunked content)
5123 $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5124
5125 if (is_wp_error($delete_result)) {
5126 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5127 }
5128 }
5129
5130
5131 /**
5132 * Deletes data from Pinecone using a source URL
5133 */
5134 public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
5135 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5136 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5137
5138 if (empty($host) || empty($api_key)) {
5139 //error_log('MXChat: Pinecone deletion failed - missing configuration');
5140 return false;
5141 }
5142
5143 $api_endpoint = "https://{$host}/vectors/delete";
5144 $vector_id = md5($source_url);
5145
5146 $request_body = array(
5147 'ids' => array($vector_id)
5148 );
5149
5150 $response = wp_remote_post($api_endpoint, array(
5151 'headers' => array(
5152 'Api-Key' => $api_key,
5153 'accept' => 'application/json',
5154 'content-type' => 'application/json'
5155 ),
5156 'body' => wp_json_encode($request_body),
5157 'timeout' => 30
5158 ));
5159
5160 if (is_wp_error($response)) {
5161 //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
5162 return false;
5163 }
5164
5165 $response_code = wp_remote_retrieve_response_code($response);
5166 if ($response_code !== 200) {
5167 //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
5168 return false;
5169 }
5170
5171 return true;
5172 }
5173
5174
5175
5176 public function mxchat_handle_product_change($post_id, $post, $update) {
5177 if ($post->post_type !== 'product') {
5178 return;
5179 }
5180
5181 if ($post->post_status === 'publish') {
5182 add_action('shutdown', function() use ($post_id) {
5183 $product = wc_get_product($post_id);
5184 if ($product) {
5185 $this->mxchat_store_product_embedding($product);
5186 }
5187 });
5188 }
5189 }
5190
5191 /**
5192 * Store WooCommerce product embeddings
5193 */
5194 private function mxchat_store_product_embedding($product) {
5195 if (!isset($this->options['enable_woocommerce_integration']) ||
5196 !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
5197 return;
5198 }
5199
5200 $source_url = get_permalink($product->get_id());
5201 $product_id = $product->get_id();
5202
5203 // Build product content
5204 $title = $product->get_name();
5205 $description = $product->get_description();
5206 $short_description = $product->get_short_description();
5207 $regular_price = $product->get_regular_price();
5208 $sale_price = $product->get_sale_price();
5209 $price = $product->get_price();
5210 $sku = $product->get_sku();
5211
5212 // Get currency symbol
5213 $currency_symbol = get_woocommerce_currency_symbol();
5214
5215 // Format content consistently
5216 $content = $title . "\n\n";
5217
5218 if (!empty($short_description)) {
5219 $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
5220 }
5221
5222 if (!empty($description)) {
5223 $content .= wp_strip_all_tags($description) . "\n\n";
5224 }
5225
5226 // Add pricing information
5227 if (!empty($regular_price)) {
5228 $content .= "Price: " . $currency_symbol . $regular_price . "\n";
5229 } elseif (!empty($price)) {
5230 $content .= "Price: " . $currency_symbol . $price . "\n";
5231 }
5232
5233 if (!empty($sale_price) && $sale_price !== $regular_price) {
5234 $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5235 }
5236
5237 // Handle variable products - show price range
5238 if ($product->is_type('variable')) {
5239 $min_price = $product->get_variation_price('min');
5240 $max_price = $product->get_variation_price('max');
5241 if ($min_price !== $max_price) {
5242 $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5243 }
5244 }
5245
5246 if (!empty($sku)) {
5247 $content .= "SKU: " . $sku . "\n";
5248 }
5249
5250 // Get product categories
5251 $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
5252 if (!empty($categories) && !is_wp_error($categories)) {
5253 $content .= "Categories: " . implode(', ', $categories) . "\n";
5254 }
5255
5256 // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
5257 $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
5258 if (!empty($custom_tabs) && is_array($custom_tabs)) {
5259 foreach ($custom_tabs as $tab) {
5260 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5261 $tab_content = isset($tab['content']) ? $tab['content'] : '';
5262
5263 if (!empty($tab_title) && !empty($tab_content)) {
5264 $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5265 }
5266 }
5267 }
5268
5269 // Also check for reusable/saved tabs applied to this product
5270 $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
5271 if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
5272 // Get the saved tabs option
5273 $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
5274 if (!empty($saved_tabs) && is_array($saved_tabs)) {
5275 foreach ($applied_saved_tabs as $saved_tab_id) {
5276 if (isset($saved_tabs[$saved_tab_id])) {
5277 $tab = $saved_tabs[$saved_tab_id];
5278 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5279 $tab_content = isset($tab['content']) ? $tab['content'] : '';
5280
5281 if (!empty($tab_title) && !empty($tab_content)) {
5282 $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5283 }
5284 }
5285 }
5286 }
5287 }
5288
5289 // Get API key with proper model detection
5290 $options = get_option('mxchat_options');
5291 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5292
5293 if (strpos($selected_model, 'voyage') === 0) {
5294 $api_key = $options['voyage_api_key'] ?? '';
5295 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5296 $api_key = $options['gemini_api_key'] ?? '';
5297 } else {
5298 $api_key = $options['api_key'] ?? '';
5299 }
5300
5301 if (empty($api_key)) {
5302 //error_log('MxChat Auto-sync: No API key configured for embedding model');
5303 return;
5304 }
5305
5306 // Use the centralized utility function for storage
5307 $result = MxChat_Utils::submit_content_to_db(
5308 $content,
5309 $source_url,
5310 $api_key,
5311 md5($source_url) // Vector ID for Pinecone
5312 );
5313
5314 // After successful storage, apply role restriction based on tags
5315 if (!is_wp_error($result)) {
5316 $this->apply_role_restriction_to_post($product_id, $source_url);
5317 }
5318
5319 if (is_wp_error($result)) {
5320 //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
5321 }
5322 }
5323
5324 public function mxchat_handle_product_delete($post_id) {
5325 if (get_post_type($post_id) !== 'product') {
5326 return;
5327 }
5328
5329 $source_url = get_permalink($post_id);
5330
5331 // Check if Pinecone is enabled
5332 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5333 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5334
5335 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5336 // Delete from Pinecone
5337 $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5338 } else {
5339 // Delete from WordPress DB
5340 global $wpdb;
5341 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5342
5343 $wpdb->delete(
5344 $table_name,
5345 array('source_url' => $source_url),
5346 array('%s')
5347 );
5348 }
5349 }
5350
5351 /**
5352 * Handle individual Pinecone content deletion
5353 */
5354 public function mxchat_handle_pinecone_prompt_delete() {
5355 // Check permissions
5356 if (!current_user_can('manage_options')) {
5357 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
5358 }
5359
5360 // Verify nonce
5361 if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
5362 wp_die(esc_html__('Security check failed.', 'mxchat'));
5363 }
5364
5365 $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
5366
5367 if (empty($vector_id)) {
5368 set_transient('mxchat_admin_notice_error',
5369 esc_html__('Invalid vector ID.', 'mxchat'),
5370 30
5371 );
5372 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5373 exit;
5374 }
5375
5376 // Get Pinecone settings
5377 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5378 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5379
5380 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5381 set_transient('mxchat_admin_notice_error',
5382 esc_html__('Pinecone is not properly configured.', 'mxchat'),
5383 30
5384 );
5385 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5386 exit;
5387 }
5388
5389 // Delete from Pinecone
5390 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5391 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5392 $vector_id,
5393 $pinecone_options['mxchat_pinecone_api_key'],
5394 $pinecone_options['mxchat_pinecone_host']
5395 );
5396
5397 if ($result['success']) {
5398 // No cache clearing needed since we removed caching
5399 set_transient('mxchat_admin_notice_success',
5400 esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
5401 30
5402 );
5403 } else {
5404 set_transient('mxchat_admin_notice_error',
5405 esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
5406 30
5407 );
5408 }
5409
5410 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5411 exit;
5412 }
5413 /**
5414 * Handle individual Pinecone content deletion via AJAX
5415 */
5416 public function ajax_mxchat_delete_pinecone_prompt() {
5417 // Verify nonce and permissions
5418 if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
5419 wp_send_json_error('Invalid nonce');
5420 exit;
5421 }
5422
5423 if (!current_user_can('manage_options')) {
5424 wp_send_json_error('Unauthorized access');
5425 exit;
5426 }
5427
5428 $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
5429 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5430
5431 if (empty($vector_id)) {
5432 wp_send_json_error('Missing vector ID');
5433 exit;
5434 }
5435
5436 // Get bot-specific Pinecone settings
5437 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5438 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5439
5440 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5441
5442 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5443 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5444 exit;
5445 }
5446
5447 // Delete from the correct Pinecone index
5448 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5449 $vector_id,
5450 $pinecone_options['mxchat_pinecone_api_key'],
5451 $pinecone_options['mxchat_pinecone_host']
5452 );
5453
5454 if ($result['success']) {
5455 // No cache clearing needed since we removed caching
5456 wp_send_json_success(array(
5457 'message' => 'Entry deleted successfully from Pinecone',
5458 'vector_id' => $vector_id,
5459 'bot_id' => $bot_id
5460 ));
5461 } else {
5462 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
5463 wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
5464 }
5465
5466 exit;
5467 }
5468
5469 /**
5470 * Handle deletion of all chunks for a given source URL via AJAX
5471 * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
5472 */
5473 public function ajax_mxchat_delete_chunks_by_url() {
5474 // Verify nonce and permissions
5475 if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
5476 wp_send_json_error('Invalid nonce');
5477 exit;
5478 }
5479
5480 if (!current_user_can('manage_options')) {
5481 wp_send_json_error('Unauthorized access');
5482 exit;
5483 }
5484
5485 $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
5486 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
5487 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5488
5489 if (empty($source_url)) {
5490 wp_send_json_error('Missing source URL');
5491 exit;
5492 }
5493
5494 // Generate the base vector ID from the source URL (same as how chunks are created)
5495 $base_vector_id = md5($source_url);
5496
5497 if ($data_source === 'pinecone') {
5498 // Get bot-specific Pinecone settings (same as working delete function)
5499 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5500 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5501
5502 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5503
5504 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5505 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5506 exit;
5507 }
5508
5509 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
5510 $host = $pinecone_options['mxchat_pinecone_host'];
5511 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
5512
5513 // Collect all vector IDs to delete
5514 $vectors_to_delete = array();
5515
5516 // Add the original single-vector ID (for non-chunked content)
5517 $vectors_to_delete[] = $base_vector_id;
5518
5519 // Use Pinecone list API to find all chunk vectors with this prefix
5520 // NOTE: Pinecone List API is a GET request with query parameters, not POST
5521 $prefix = $base_vector_id . '_chunk_';
5522
5523 $query_params = array(
5524 'prefix' => $prefix,
5525 'limit' => 100
5526 );
5527
5528 if (!empty($namespace)) {
5529 $query_params['namespace'] = $namespace;
5530 }
5531
5532 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
5533
5534 $list_response = wp_remote_get($list_url, array(
5535 'headers' => array(
5536 'Api-Key' => $api_key,
5537 'accept' => 'application/json'
5538 ),
5539 'timeout' => 30
5540 ));
5541
5542 if (!is_wp_error($list_response)) {
5543 $list_body_response = wp_remote_retrieve_body($list_response);
5544 $list_data = json_decode($list_body_response, true);
5545 if (!empty($list_data['vectors'])) {
5546 foreach ($list_data['vectors'] as $vector) {
5547 if (isset($vector['id'])) {
5548 $vectors_to_delete[] = $vector['id'];
5549 }
5550 }
5551 }
5552 }
5553
5554 if (empty($vectors_to_delete)) {
5555 wp_send_json_success(array(
5556 'message' => 'No vectors found to delete',
5557 'source_url' => $source_url
5558 ));
5559 exit;
5560 }
5561
5562 // Delete all vectors using the same endpoint as the working function
5563 $delete_url = "https://{$host}/vectors/delete";
5564
5565 $delete_body = array(
5566 'ids' => $vectors_to_delete
5567 );
5568
5569 if (!empty($namespace)) {
5570 $delete_body['namespace'] = $namespace;
5571 }
5572
5573 $delete_response = wp_remote_post($delete_url, array(
5574 'headers' => array(
5575 'Api-Key' => $api_key,
5576 'accept' => 'application/json',
5577 'content-type' => 'application/json'
5578 ),
5579 'body' => wp_json_encode($delete_body),
5580 'timeout' => 30
5581 ));
5582
5583 if (is_wp_error($delete_response)) {
5584 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
5585 wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
5586 exit;
5587 }
5588
5589 $response_code = wp_remote_retrieve_response_code($delete_response);
5590
5591 if ($response_code !== 200) {
5592 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
5593 wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
5594 exit;
5595 }
5596
5597 wp_send_json_success(array(
5598 'message' => 'All chunks deleted successfully from Pinecone',
5599 'source_url' => $source_url,
5600 'deleted_count' => count($vectors_to_delete)
5601 ));
5602
5603 } else {
5604 // WordPress database deletion
5605 global $wpdb;
5606 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5607
5608 $result = $wpdb->delete(
5609 $table_name,
5610 array('source_url' => $source_url),
5611 array('%s')
5612 );
5613
5614 if ($result === false) {
5615 MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
5616 wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
5617 exit;
5618 }
5619
5620 wp_send_json_success(array(
5621 'message' => 'All chunks deleted successfully from database',
5622 'source_url' => $source_url,
5623 'deleted_count' => $result
5624 ));
5625 }
5626
5627 exit;
5628 }
5629
5630 /**
5631 * Handle individual WordPress database content deletion via AJAX
5632 * Mirrors the Pinecone delete handler but for WordPress database entries
5633 */
5634 public function ajax_mxchat_delete_wordpress_prompt() {
5635 // Verify nonce and permissions
5636 if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
5637 wp_send_json_error('Invalid nonce');
5638 exit;
5639 }
5640
5641 if (!current_user_can('manage_options')) {
5642 wp_send_json_error('Unauthorized access');
5643 exit;
5644 }
5645
5646 $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
5647
5648 if (empty($entry_id)) {
5649 wp_send_json_error('Missing entry ID');
5650 exit;
5651 }
5652
5653 global $wpdb;
5654 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5655
5656 // Clear cache for this entry
5657 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5658
5659 // Delete from database
5660 $result = $wpdb->delete(
5661 $table_name,
5662 array('id' => $entry_id),
5663 array('%d')
5664 );
5665
5666 if ($result !== false) {
5667 wp_send_json_success(array(
5668 'message' => 'Entry deleted successfully',
5669 'entry_id' => $entry_id
5670 ));
5671 } else {
5672 wp_send_json_error('Failed to delete entry from database');
5673 }
5674
5675 exit;
5676 }
5677
5678 /**
5679 * Handle bulk deletion of knowledge entries via AJAX
5680 * Supports both Pinecone and WordPress database entries
5681 */
5682 public function ajax_mxchat_bulk_delete_knowledge() {
5683 // Verify nonce and permissions
5684 if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
5685 wp_send_json_error('Invalid nonce');
5686 exit;
5687 }
5688
5689 if (!current_user_can('manage_options')) {
5690 wp_send_json_error('Unauthorized access');
5691 exit;
5692 }
5693
5694 $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
5695 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5696
5697 if (empty($entries) || !is_array($entries)) {
5698 wp_send_json_error('No entries provided');
5699 exit;
5700 }
5701
5702 // Extend execution time — bulk Pinecone operations can take a while
5703 if (function_exists('set_time_limit')) {
5704 set_time_limit(120);
5705 }
5706
5707 $success_ids = array();
5708 $failed_ids = array();
5709 $errors = array();
5710
5711 global $wpdb;
5712 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5713
5714 // Get Pinecone manager for Pinecone deletions
5715 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5716 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5717 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5718
5719 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5720 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5721
5722 // =============================================
5723 // PHASE 1: Collect all Pinecone vector IDs
5724 // and separate WordPress entries
5725 // =============================================
5726 $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
5727 $wordpress_entries = array(); // entries for WordPress DB deletion
5728 $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
5729
5730 foreach ($entries as $entry) {
5731 $entry_id = sanitize_text_field($entry['id'] ?? '');
5732 $source = sanitize_text_field($entry['source'] ?? 'wordpress');
5733 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
5734 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
5735
5736 if (empty($entry_id)) {
5737 continue;
5738 }
5739
5740 if ($source === 'pinecone') {
5741 if (!$use_pinecone || empty($api_key)) {
5742 $failed_ids[] = $entry_id;
5743 $errors[] = "Pinecone not configured for entry: $entry_id";
5744 continue;
5745 }
5746
5747 $pinecone_entry_ids[] = $entry_id;
5748
5749 if ($is_group && !empty($source_url)) {
5750 // Grouped/chunked entry: collect base ID + chunk IDs via List API
5751 $base_vector_id = md5($source_url);
5752 $all_vector_ids[] = $base_vector_id;
5753
5754 $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
5755 $list_response = wp_remote_get($list_url, array(
5756 'headers' => array(
5757 'Api-Key' => $api_key,
5758 'accept' => 'application/json'
5759 ),
5760 'timeout' => 30
5761 ));
5762
5763 if (!is_wp_error($list_response)) {
5764 $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
5765 if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
5766 foreach ($list_body['vectors'] as $vector) {
5767 if (isset($vector['id'])) {
5768 $all_vector_ids[] = $vector['id'];
5769 }
5770 }
5771 }
5772 }
5773 } else {
5774 // Single entry: the entry_id IS the vector ID
5775 $all_vector_ids[] = $entry_id;
5776 }
5777 } else {
5778 $wordpress_entries[] = $entry;
5779 }
5780 }
5781
5782 // =============================================
5783 // PHASE 2: Single batch delete to Pinecone
5784 // =============================================
5785 if (!empty($all_vector_ids)) {
5786 $all_vector_ids = array_values(array_unique($all_vector_ids));
5787 $pinecone_success = true;
5788 $batches = array_chunk($all_vector_ids, 100);
5789
5790 foreach ($batches as $batch) {
5791 $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
5792 'headers' => array(
5793 'Api-Key' => $api_key,
5794 'accept' => 'application/json',
5795 'content-type' => 'application/json'
5796 ),
5797 'body' => wp_json_encode(array('ids' => $batch)),
5798 'timeout' => 60
5799 ));
5800
5801 if (is_wp_error($delete_response)) {
5802 $pinecone_success = false;
5803 $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
5804 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
5805 } else {
5806 $response_code = wp_remote_retrieve_response_code($delete_response);
5807 if ($response_code !== 200) {
5808 $pinecone_success = false;
5809 $response_body = wp_remote_retrieve_body($delete_response);
5810 $errors[] = "Pinecone API error (HTTP $response_code)";
5811 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
5812 }
5813 }
5814 }
5815
5816 // Mark all pinecone entries based on batch result
5817 foreach ($pinecone_entry_ids as $eid) {
5818 if ($pinecone_success) {
5819 $success_ids[] = $eid;
5820 } else {
5821 $failed_ids[] = $eid;
5822 }
5823 }
5824 }
5825
5826 // =============================================
5827 // PHASE 3: WordPress database deletions
5828 // =============================================
5829 foreach ($wordpress_entries as $entry) {
5830 $entry_id = sanitize_text_field($entry['id'] ?? '');
5831 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
5832 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
5833
5834 if (empty($entry_id)) {
5835 continue;
5836 }
5837
5838 try {
5839 if ($is_group && !empty($source_url)) {
5840 $result = $wpdb->delete(
5841 $table_name,
5842 array('source_url' => $source_url),
5843 array('%s')
5844 );
5845 } else {
5846 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5847 $result = $wpdb->delete(
5848 $table_name,
5849 array('id' => intval($entry_id)),
5850 array('%d')
5851 );
5852 }
5853
5854 if ($result !== false) {
5855 $success_ids[] = $entry_id;
5856 } else {
5857 $failed_ids[] = $entry_id;
5858 $errors[] = "Database error for entry: $entry_id";
5859 }
5860 } catch (Exception $e) {
5861 $failed_ids[] = $entry_id;
5862 $errors[] = $e->getMessage();
5863 }
5864 }
5865
5866 wp_send_json_success(array(
5867 'success_ids' => $success_ids,
5868 'failed_ids' => $failed_ids,
5869 'errors' => $errors,
5870 'total_processed' => count($success_ids) + count($failed_ids)
5871 ));
5872
5873 exit;
5874 }
5875
5876 /**
5877 * Get hierarchical roles for dropdown
5878 */
5879 public function mxchat_get_role_options() {
5880 return array(
5881 'public' => __('Public (Everyone)', 'mxchat'),
5882 'logged_in' => __('Logged In Users', 'mxchat'),
5883 'subscriber' => __('Subscribers & Above', 'mxchat'),
5884 'contributor' => __('Contributors & Above', 'mxchat'),
5885 'author' => __('Authors & Above', 'mxchat'),
5886 'editor' => __('Editors & Above', 'mxchat'),
5887 'administrator' => __('Administrators Only', 'mxchat')
5888 );
5889 }
5890
5891 /**
5892 * Check if user has access to content based on role restriction
5893 */
5894 public function mxchat_user_has_content_access($role_restriction) {
5895 // Public content is always accessible
5896 if ($role_restriction === 'public' || empty($role_restriction)) {
5897 return true;
5898 }
5899
5900 // Check if user is logged in for logged_in restriction
5901 if ($role_restriction === 'logged_in') {
5902 return is_user_logged_in();
5903 }
5904
5905 // If not logged in, no access to role-restricted content
5906 if (!is_user_logged_in()) {
5907 return false;
5908 }
5909
5910 $user = wp_get_current_user();
5911 $user_roles = $user->roles;
5912
5913 if (empty($user_roles)) {
5914 return false;
5915 }
5916
5917 // Define role hierarchy (higher number = higher access)
5918 $hierarchy = array(
5919 'subscriber' => 1,
5920 'contributor' => 2,
5921 'author' => 3,
5922 'editor' => 4,
5923 'administrator' => 5
5924 );
5925
5926 // Get required level
5927 $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
5928
5929 // Check if user has required level or higher
5930 foreach ($user_roles as $user_role) {
5931 $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
5932 if ($user_level >= $required_level) {
5933 return true;
5934 }
5935 }
5936
5937 return false;
5938 }
5939
5940 /**
5941 * Handle role restriction updates via AJAX
5942 * Removed cache clearing call since we removed caching
5943 */
5944 public function ajax_mxchat_update_role_restriction() {
5945 // Verify nonce and permissions
5946 if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
5947 wp_send_json_error('Invalid nonce');
5948 exit;
5949 }
5950
5951 if (!current_user_can('manage_options')) {
5952 wp_send_json_error('Unauthorized access');
5953 exit;
5954 }
5955
5956 $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
5957 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
5958 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
5959
5960 if (empty($entry_id)) {
5961 wp_send_json_error('Invalid entry ID');
5962 exit;
5963 }
5964
5965 // Get knowledge manager instance to validate role restriction
5966 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5967 $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
5968 if (!in_array($role_restriction, $valid_roles)) {
5969 wp_send_json_error('Invalid role restriction');
5970 exit;
5971 }
5972
5973 global $wpdb;
5974
5975 if ($data_source === 'pinecone') {
5976 // Handle Pinecone role restriction (stored separately in WordPress table)
5977 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5978
5979 // Use REPLACE to insert or update the role restriction
5980 $result = $wpdb->replace(
5981 $roles_table,
5982 array(
5983 'vector_id' => $entry_id,
5984 'role_restriction' => $role_restriction,
5985 'updated_at' => current_time('mysql')
5986 ),
5987 array('%s', '%s', '%s')
5988 );
5989
5990 // No cache clearing needed since we removed caching
5991
5992 } else {
5993 // Handle WordPress database role restriction (existing functionality)
5994 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5995
5996 $result = $wpdb->update(
5997 $table_name,
5998 array('role_restriction' => $role_restriction),
5999 array('id' => absint($entry_id)),
6000 array('%s'),
6001 array('%d')
6002 );
6003 }
6004
6005 if ($result === false) {
6006 wp_send_json_error('Database update failed: ' . $wpdb->last_error);
6007 exit;
6008 }
6009
6010 wp_send_json_success(array(
6011 'message' => 'Role restriction updated successfully',
6012 'role_restriction' => $role_restriction,
6013 'data_source' => $data_source,
6014 'entry_id' => $entry_id
6015 ));
6016 exit;
6017 }
6018
6019 // ========================================
6020 // ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
6021 // Add these to your MxChat_Knowledge_Manager class
6022 // ========================================
6023
6024 /**
6025 * Initialize role-based content hooks
6026 * Add this call to your __construct() or mxchat_init_hooks() method
6027 */
6028 private function mxchat_init_role_hooks() {
6029 // AJAX handlers for tag-role mappings
6030 add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
6031 add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
6032 add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
6033 add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
6034
6035 // Hook to automatically update role restrictions when tags are added/removed
6036 add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
6037
6038 // Hook to apply role restrictions on auto-sync
6039 add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
6040 }
6041
6042 /**
6043 * Add tag-role mapping via AJAX
6044 */
6045 public function ajax_add_tag_role_mapping() {
6046 // Verify nonce and permissions
6047 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6048
6049 if (!current_user_can('manage_options')) {
6050 wp_send_json_error('Unauthorized access');
6051 exit;
6052 }
6053
6054 $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6055 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6056
6057 if (empty($tag_slug)) {
6058 wp_send_json_error('Tag slug is required');
6059 exit;
6060 }
6061
6062 // Validate role restriction
6063 $valid_roles = array_keys($this->mxchat_get_role_options());
6064 if (!in_array($role_restriction, $valid_roles)) {
6065 wp_send_json_error('Invalid role restriction');
6066 exit;
6067 }
6068
6069 // Check if tag exists in WordPress
6070 $term = get_term_by('slug', $tag_slug, 'post_tag');
6071 if (!$term) {
6072 wp_send_json_error('Tag does not exist in WordPress');
6073 exit;
6074 }
6075
6076 // Get existing mappings
6077 $mappings = get_option('mxchat_tag_role_mappings', array());
6078
6079 // Check if mapping already exists
6080 if (isset($mappings[$tag_slug])) {
6081 wp_send_json_error('Mapping for this tag already exists');
6082 exit;
6083 }
6084
6085 // Add new mapping
6086 $mappings[$tag_slug] = $role_restriction;
6087 update_option('mxchat_tag_role_mappings', $mappings);
6088
6089 wp_send_json_success(array(
6090 'message' => 'Tag-role mapping added successfully',
6091 'tag_slug' => $tag_slug,
6092 'role_restriction' => $role_restriction
6093 ));
6094 exit;
6095 }
6096
6097 /**
6098 * Delete tag-role mapping via AJAX
6099 */
6100 public function ajax_delete_tag_role_mapping() {
6101 // Verify nonce and permissions
6102 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6103
6104 if (!current_user_can('manage_options')) {
6105 wp_send_json_error('Unauthorized access');
6106 exit;
6107 }
6108
6109 $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6110
6111 if (empty($tag_slug)) {
6112 wp_send_json_error('Tag slug is required');
6113 exit;
6114 }
6115
6116 // Get existing mappings
6117 $mappings = get_option('mxchat_tag_role_mappings', array());
6118
6119 // Check if mapping exists
6120 if (!isset($mappings[$tag_slug])) {
6121 wp_send_json_error('Mapping does not exist');
6122 exit;
6123 }
6124
6125 // Remove mapping
6126 unset($mappings[$tag_slug]);
6127 update_option('mxchat_tag_role_mappings', $mappings);
6128
6129 wp_send_json_success(array(
6130 'message' => 'Tag-role mapping deleted successfully',
6131 'tag_slug' => $tag_slug
6132 ));
6133 exit;
6134 }
6135
6136 /**
6137 * Get all tag-role mappings via AJAX
6138 */
6139 public function ajax_get_tag_role_mappings() {
6140 // Verify nonce and permissions
6141 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6142
6143 if (!current_user_can('manage_options')) {
6144 wp_send_json_error('Unauthorized access');
6145 exit;
6146 }
6147
6148 // Get mappings
6149 $mappings = get_option('mxchat_tag_role_mappings', array());
6150 $role_options = $this->mxchat_get_role_options();
6151
6152 $formatted_mappings = array();
6153
6154 foreach ($mappings as $tag_slug => $role_restriction) {
6155 // Get tag object
6156 $term = get_term_by('slug', $tag_slug, 'post_tag');
6157
6158 // Count posts with this tag
6159 $post_count = 0;
6160 if ($term) {
6161 $post_count = $term->count;
6162 }
6163
6164 $formatted_mappings[] = array(
6165 'tag_slug' => $tag_slug,
6166 'role_restriction' => $role_restriction,
6167 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
6168 'post_count' => $post_count
6169 );
6170 }
6171
6172 wp_send_json_success(array(
6173 'mappings' => $formatted_mappings
6174 ));
6175 exit;
6176 }
6177
6178 /**
6179 * Bulk update role restrictions for all existing content with mapped tags
6180 */
6181 public function ajax_bulk_update_tag_roles() {
6182 // Verify nonce and permissions
6183 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6184
6185 if (!current_user_can('manage_options')) {
6186 wp_send_json_error('Unauthorized access');
6187 exit;
6188 }
6189
6190 // Get mappings
6191 $mappings = get_option('mxchat_tag_role_mappings', array());
6192
6193 if (empty($mappings)) {
6194 wp_send_json_error('No tag-role mappings found');
6195 exit;
6196 }
6197
6198 global $wpdb;
6199
6200 // Check if using Pinecone
6201 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6202 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6203
6204 $updated_count = 0;
6205 $details = array();
6206
6207 foreach ($mappings as $tag_slug => $role_restriction) {
6208 // Get all posts with this tag
6209 $posts = get_posts(array(
6210 'tag' => $tag_slug,
6211 'post_type' => 'any',
6212 'posts_per_page' => -1,
6213 'fields' => 'ids',
6214 'post_status' => 'publish'
6215 ));
6216
6217 if (empty($posts)) {
6218 continue;
6219 }
6220
6221 $tag_updated = 0;
6222
6223 foreach ($posts as $post_id) {
6224 $source_url = get_permalink($post_id);
6225 if (!$source_url) {
6226 continue;
6227 }
6228
6229 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6230 // Update Pinecone role restriction
6231 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6232 $vector_id = md5($source_url);
6233
6234 $result = $wpdb->replace(
6235 $roles_table,
6236 array(
6237 'vector_id' => $vector_id,
6238 'role_restriction' => $role_restriction,
6239 'updated_at' => current_time('mysql')
6240 ),
6241 array('%s', '%s', '%s')
6242 );
6243 } else {
6244 // Update WordPress DB
6245 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6246
6247 $result = $wpdb->update(
6248 $table_name,
6249 array('role_restriction' => $role_restriction),
6250 array('source_url' => $source_url),
6251 array('%s'),
6252 array('%s')
6253 );
6254 }
6255
6256 if ($result !== false) {
6257 $tag_updated++;
6258 $updated_count++;
6259 }
6260 }
6261
6262 if ($tag_updated > 0) {
6263 $details[] = sprintf(
6264 'Tag "%s" (%s): %d posts updated',
6265 $tag_slug,
6266 $role_restriction,
6267 $tag_updated
6268 );
6269 }
6270 }
6271
6272 wp_send_json_success(array(
6273 'message' => 'Bulk update completed',
6274 'updated_count' => $updated_count,
6275 'tags_processed' => count($mappings),
6276 'details' => $details
6277 ));
6278 exit;
6279 }
6280
6281 /**
6282 * Handle tag changes on posts (when tags are added or removed)
6283 */
6284 public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
6285 // Only process post tags
6286 if ($taxonomy !== 'post_tag') {
6287 return;
6288 }
6289
6290 // Get tag-role mappings
6291 $mappings = get_option('mxchat_tag_role_mappings', array());
6292
6293 if (empty($mappings)) {
6294 return;
6295 }
6296
6297 // Get the post's URL
6298 $source_url = get_permalink($object_id);
6299 if (!$source_url) {
6300 return;
6301 }
6302
6303 // Determine the highest role restriction based on tags
6304 $highest_role = 'public';
6305 $role_hierarchy = array(
6306 'public' => 0,
6307 'logged_in' => 1,
6308 'subscriber' => 2,
6309 'contributor' => 3,
6310 'author' => 4,
6311 'editor' => 5,
6312 'administrator' => 6
6313 );
6314
6315 // Get all current tags for the post
6316 $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
6317
6318 // Find the highest role restriction among the tags
6319 foreach ($current_tags as $tag_slug) {
6320 if (isset($mappings[$tag_slug])) {
6321 $role = $mappings[$tag_slug];
6322 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6323 $highest_role = $role;
6324 }
6325 }
6326 }
6327
6328 // Update the role restriction in the database
6329 global $wpdb;
6330
6331 // Check if using Pinecone
6332 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6333 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6334
6335 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6336 // Update Pinecone role restriction
6337 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6338 $vector_id = md5($source_url);
6339
6340 $wpdb->replace(
6341 $roles_table,
6342 array(
6343 'vector_id' => $vector_id,
6344 'role_restriction' => $highest_role,
6345 'updated_at' => current_time('mysql')
6346 ),
6347 array('%s', '%s', '%s')
6348 );
6349 } else {
6350 // Update WordPress DB
6351 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6352
6353 $wpdb->update(
6354 $table_name,
6355 array('role_restriction' => $highest_role),
6356 array('source_url' => $source_url),
6357 array('%s'),
6358 array('%s')
6359 );
6360 }
6361 }
6362
6363 /**
6364 * Apply role restriction after content is stored (for auto-sync)
6365 */
6366 public function apply_role_restriction_after_storage($post_id, $source_url) {
6367 // Get tag-role mappings
6368 $mappings = get_option('mxchat_tag_role_mappings', array());
6369
6370 if (empty($mappings)) {
6371 return;
6372 }
6373
6374 // Get all tags for the post
6375 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
6376
6377 if (empty($post_tags)) {
6378 return;
6379 }
6380
6381 // Determine the highest role restriction based on tags
6382 $highest_role = 'public';
6383 $role_hierarchy = array(
6384 'public' => 0,
6385 'logged_in' => 1,
6386 'subscriber' => 2,
6387 'contributor' => 3,
6388 'author' => 4,
6389 'editor' => 5,
6390 'administrator' => 6
6391 );
6392
6393 foreach ($post_tags as $tag_slug) {
6394 if (isset($mappings[$tag_slug])) {
6395 $role = $mappings[$tag_slug];
6396 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6397 $highest_role = $role;
6398 }
6399 }
6400 }
6401
6402 // If no restricted tags found, return (leave as public)
6403 if ($highest_role === 'public') {
6404 return;
6405 }
6406
6407 // Update the role restriction
6408 global $wpdb;
6409
6410 // Check if using Pinecone
6411 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6412 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6413
6414 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6415 // Update Pinecone role restriction
6416 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6417 $vector_id = md5($source_url);
6418
6419 $wpdb->replace(
6420 $roles_table,
6421 array(
6422 'vector_id' => $vector_id,
6423 'role_restriction' => $highest_role,
6424 'updated_at' => current_time('mysql')
6425 ),
6426 array('%s', '%s', '%s')
6427 );
6428 } else {
6429 // Update WordPress DB
6430 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6431
6432 $wpdb->update(
6433 $table_name,
6434 array('role_restriction' => $highest_role),
6435 array('source_url' => $source_url),
6436 array('%s'),
6437 array('%s')
6438 );
6439 }
6440 }
6441
6442
6443 // ========================================
6444 // HELPER METHODS
6445 // ========================================
6446
6447 /**
6448 * Check if user has required permissions for content processing
6449 */
6450 private function mxchat_check_user_permissions() {
6451 if (!current_user_can('manage_options')) {
6452 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
6453 }
6454 }
6455
6456 /**
6457 * Validate nonce for security
6458 */
6459 private function mxchat_validate_nonce($nonce_name, $nonce_action) {
6460 if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
6461 wp_die(esc_html__('Security check failed.', 'mxchat'));
6462 }
6463 }
6464
6465 /**
6466 * Get embedding API credentials
6467 */
6468 private function mxchat_get_embedding_credentials() {
6469 $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
6470
6471 if (strpos($embedding_model, 'text-embedding-') !== false) {
6472 return array(
6473 'type' => 'openai',
6474 'api_key' => $this->options['api_key'] ?? ''
6475 );
6476 } elseif (strpos($embedding_model, 'voyage-') !== false) {
6477 return array(
6478 'type' => 'voyage',
6479 'api_key' => $this->options['voyage_api_key'] ?? ''
6480 );
6481 } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
6482 return array(
6483 'type' => 'gemini',
6484 'api_key' => $this->options['gemini_api_key'] ?? ''
6485 );
6486 }
6487
6488 return array('type' => 'unknown', 'api_key' => '');
6489 }
6490
6491 /**
6492 * Log processing errors
6493 */
6494 private function mxchat_log_processing_error($operation, $error_message) {
6495 //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
6496 }
6497
6498 /**
6499 * Set admin notice transient
6500 */
6501 private function mxchat_set_admin_notice($type, $message) {
6502 set_transient("mxchat_admin_notice_{$type}", $message, 30);
6503 }
6504
6505 /**
6506 * Get Pinecone manager instance for vector operations
6507 */
6508 private function mxchat_get_pinecone_manager() {
6509 return MxChat_Pinecone_Manager::get_instance();
6510 }
6511
6512
6513 // ========================================
6514 // DATABASE QUEUE TABLE MANAGEMENT
6515 // ========================================
6516
6517 /**
6518 * Create queue table on plugin activation
6519 * Call this from your plugin activation hook
6520 */
6521 public function mxchat_create_queue_table() {
6522 global $wpdb;
6523
6524 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6525 $charset_collate = $wpdb->get_charset_collate();
6526
6527 $sql = "CREATE TABLE IF NOT EXISTS $table_name (
6528 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6529 queue_id varchar(64) NOT NULL,
6530 item_type varchar(20) NOT NULL,
6531 item_data longtext NOT NULL,
6532 status varchar(20) NOT NULL DEFAULT 'pending',
6533 bot_id varchar(50) NOT NULL DEFAULT 'default',
6534 priority int(11) NOT NULL DEFAULT 0,
6535 attempts int(11) NOT NULL DEFAULT 0,
6536 max_attempts int(11) NOT NULL DEFAULT 3,
6537 error_message text DEFAULT NULL,
6538 created_at datetime NOT NULL,
6539 started_at datetime DEFAULT NULL,
6540 completed_at datetime DEFAULT NULL,
6541 PRIMARY KEY (id),
6542 KEY queue_id (queue_id),
6543 KEY status (status),
6544 KEY item_type (item_type),
6545 KEY priority (priority)
6546 ) $charset_collate;";
6547
6548 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
6549 dbDelta($sql);
6550
6551 // Also create a meta table for queue metadata
6552 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6553
6554 $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
6555 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6556 queue_id varchar(64) NOT NULL,
6557 meta_key varchar(255) NOT NULL,
6558 meta_value longtext,
6559 PRIMARY KEY (id),
6560 KEY queue_id (queue_id),
6561 KEY meta_key (meta_key)
6562 ) $charset_collate;";
6563
6564 dbDelta($meta_sql);
6565 }
6566
6567 /**
6568 * Add items to the processing queue
6569 *
6570 * @param string $queue_id Unique identifier for this queue batch
6571 * @param string $item_type Type of item (url, pdf_page)
6572 * @param array $items Array of items to queue
6573 * @param string $bot_id Bot ID for processing
6574 * @return int Number of items queued
6575 */
6576 private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
6577 global $wpdb;
6578 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6579
6580 $queued_count = 0;
6581 $priority = 0;
6582
6583 foreach ($items as $item) {
6584 $result = $wpdb->insert(
6585 $table_name,
6586 array(
6587 'queue_id' => $queue_id,
6588 'item_type' => $item_type,
6589 'item_data' => wp_json_encode($item),
6590 'status' => 'pending',
6591 'bot_id' => $bot_id,
6592 'priority' => $priority,
6593 'attempts' => 0,
6594 'max_attempts' => 3,
6595 'created_at' => current_time('mysql')
6596 ),
6597 array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
6598 );
6599
6600 if ($result) {
6601 $queued_count++;
6602 }
6603
6604 $priority++; // Process in order
6605 }
6606
6607 return $queued_count;
6608 }
6609
6610 /**
6611 * Store queue metadata (total counts, source URL, etc.)
6612 */
6613 private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
6614 global $wpdb;
6615 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6616
6617 // Check if meta exists
6618 $existing = $wpdb->get_var($wpdb->prepare(
6619 "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6620 $queue_id,
6621 $meta_key
6622 ));
6623
6624 if ($existing) {
6625 // Update
6626 $wpdb->update(
6627 $meta_table,
6628 array('meta_value' => maybe_serialize($meta_value)),
6629 array('queue_id' => $queue_id, 'meta_key' => $meta_key),
6630 array('%s'),
6631 array('%s', '%s')
6632 );
6633 } else {
6634 // Insert
6635 $wpdb->insert(
6636 $meta_table,
6637 array(
6638 'queue_id' => $queue_id,
6639 'meta_key' => $meta_key,
6640 'meta_value' => maybe_serialize($meta_value)
6641 ),
6642 array('%s', '%s', '%s')
6643 );
6644 }
6645 }
6646
6647 /**
6648 * Get queue metadata
6649 */
6650 private function mxchat_get_queue_meta($queue_id, $meta_key) {
6651 global $wpdb;
6652 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6653
6654 $value = $wpdb->get_var($wpdb->prepare(
6655 "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6656 $queue_id,
6657 $meta_key
6658 ));
6659
6660 return maybe_unserialize($value);
6661 }
6662
6663 // ========================================
6664 // AJAX QUEUE PROCESSING HANDLERS
6665 // ========================================
6666
6667 /**
6668 * AJAX: Get next item from queue to process
6669 */
6670 public function ajax_mxchat_get_next_queue_item() {
6671 // Verify nonce and permissions
6672 check_ajax_referer('mxchat_queue_nonce', 'nonce');
6673
6674 if (!current_user_can('manage_options')) {
6675 wp_send_json_error('Unauthorized access');
6676 }
6677
6678 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
6679
6680 if (empty($queue_id)) {
6681 wp_send_json_error('Missing queue ID');
6682 }
6683
6684 global $wpdb;
6685 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6686
6687 // Get next pending item with retry logic for failed items
6688 $next_item = $wpdb->get_row($wpdb->prepare(
6689 "SELECT * FROM $table_name
6690 WHERE queue_id = %s
6691 AND status IN ('pending', 'failed')
6692 AND attempts < max_attempts
6693 ORDER BY priority ASC, id ASC
6694 LIMIT 1",
6695 $queue_id
6696 ));
6697
6698 if (!$next_item) {
6699 // No more items - queue complete
6700 wp_send_json_success(array(
6701 'complete' => true,
6702 'message' => 'Queue processing complete'
6703 ));
6704 }
6705
6706 // Mark item as processing
6707 $wpdb->update(
6708 $table_name,
6709 array(
6710 'status' => 'processing',
6711 'started_at' => current_time('mysql'),
6712 'attempts' => $next_item->attempts + 1
6713 ),
6714 array('id' => $next_item->id),
6715 array('%s', '%s', '%d'),
6716 array('%d')
6717 );
6718
6719 wp_send_json_success(array(
6720 'complete' => false,
6721 'item' => array(
6722 'id' => $next_item->id,
6723 'type' => $next_item->item_type,
6724 'data' => json_decode($next_item->item_data, true),
6725 'bot_id' => $next_item->bot_id,
6726 'attempt' => $next_item->attempts + 1
6727 )
6728 ));
6729 }
6730
6731 /**
6732 * AJAX: Process a single queue item
6733 */
6734 public function ajax_mxchat_process_queue_item() {
6735 // Verify nonce and permissions
6736 check_ajax_referer('mxchat_queue_nonce', 'nonce');
6737
6738 if (!current_user_can('manage_options')) {
6739 wp_send_json_error('Unauthorized access');
6740 }
6741
6742 $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
6743 $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
6744 $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
6745 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
6746
6747 if (empty($item_id) || empty($item_type)) {
6748 wp_send_json_error('Missing item data');
6749 }
6750
6751 global $wpdb;
6752 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6753
6754 // Process based on item type
6755 try {
6756 set_time_limit(60); // Give processing 60 seconds
6757
6758 $result = false;
6759 $error_message = '';
6760
6761 switch ($item_type) {
6762 case 'url':
6763 $result = $this->mxchat_process_queue_url($item_data, $bot_id);
6764 break;
6765
6766 case 'pdf_page':
6767 $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
6768 break;
6769
6770 default:
6771 throw new Exception('Unknown item type: ' . $item_type);
6772 }
6773
6774 if (is_wp_error($result)) {
6775 throw new Exception($result->get_error_message());
6776 }
6777
6778 if ($result === false) {
6779 throw new Exception('Processing returned false - item may be empty or invalid');
6780 }
6781
6782 // Mark as completed
6783 $wpdb->update(
6784 $table_name,
6785 array(
6786 'status' => 'completed',
6787 'completed_at' => current_time('mysql'),
6788 'error_message' => null
6789 ),
6790 array('id' => $item_id),
6791 array('%s', '%s', '%s'),
6792 array('%d')
6793 );
6794
6795 wp_send_json_success(array(
6796 'processed' => true,
6797 'item_id' => $item_id,
6798 'message' => 'Item processed successfully'
6799 ));
6800
6801 } catch (Exception $e) {
6802 $error_message = $e->getMessage();
6803
6804 // Get current attempt count
6805 $item = $wpdb->get_row($wpdb->prepare(
6806 "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
6807 $item_id
6808 ));
6809
6810 // Check if we've exhausted retries
6811 if ($item && $item->attempts >= $item->max_attempts) {
6812 // Permanently failed
6813 $wpdb->update(
6814 $table_name,
6815 array(
6816 'status' => 'failed',
6817 'error_message' => $error_message
6818 ),
6819 array('id' => $item_id),
6820 array('%s', '%s'),
6821 array('%d')
6822 );
6823
6824 wp_send_json_error(array(
6825 'message' => 'Item failed after maximum attempts: ' . $error_message,
6826 'permanent_failure' => true,
6827 'item_id' => $item_id
6828 ));
6829 } else {
6830 // Mark for retry
6831 $wpdb->update(
6832 $table_name,
6833 array(
6834 'status' => 'failed',
6835 'error_message' => $error_message
6836 ),
6837 array('id' => $item_id),
6838 array('%s', '%s'),
6839 array('%d')
6840 );
6841
6842 wp_send_json_error(array(
6843 'message' => 'Item processing failed, will retry: ' . $error_message,
6844 'can_retry' => true,
6845 'item_id' => $item_id,
6846 'attempts' => $item ? $item->attempts : 0
6847 ));
6848 }
6849 }
6850 }
6851
6852 /**
6853 * Process a URL from the queue
6854 */
6855 private function mxchat_process_queue_url($item_data, $bot_id = 'default') {
6856 $url = isset($item_data['url']) ? $item_data['url'] : '';
6857
6858 if (empty($url)) {
6859 return new WP_Error('invalid_url', 'URL is empty');
6860 }
6861
6862 // Get bot-specific API key early (needed for both paths)
6863 $bot_options = $this->get_bot_options($bot_id);
6864 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6865 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6866
6867 if (strpos($selected_model, 'voyage') === 0) {
6868 $api_key = $options['voyage_api_key'] ?? '';
6869 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6870 $api_key = $options['gemini_api_key'] ?? '';
6871 } else {
6872 $api_key = $options['api_key'] ?? '';
6873 }
6874
6875 if (empty($api_key)) {
6876 return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
6877 }
6878
6879 // Check if this is a WooCommerce product URL and WooCommerce is active
6880 $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
6881 $content_type = $is_product_url ? 'product' : 'url';
6882
6883 // Try to get WooCommerce product data if it's a product URL
6884 if ($is_product_url && class_exists('WooCommerce')) {
6885 $product_content = $this->mxchat_extract_woocommerce_product_content($url);
6886
6887 if (!empty($product_content)) {
6888 // Successfully extracted WooCommerce product data with pricing
6889 $result = MxChat_Utils::submit_content_to_db(
6890 $product_content,
6891 $url,
6892 $api_key,
6893 null,
6894 $bot_id,
6895 'product'
6896 );
6897 return $result;
6898 }
6899 // If WooCommerce extraction failed, fall through to HTML extraction
6900 }
6901
6902 // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
6903 $response = wp_remote_get($url, array(
6904 'timeout' => 30,
6905 'redirection' => 5,
6906 'user-agent' => 'MxChat/1.0'
6907 ));
6908
6909 if (is_wp_error($response)) {
6910 return $response;
6911 }
6912
6913 $response_code = wp_remote_retrieve_response_code($response);
6914 if ($response_code !== 200) {
6915 return new WP_Error('http_error', 'HTTP ' . $response_code . ' error');
6916 }
6917
6918 $html = wp_remote_retrieve_body($response);
6919
6920 if (empty($html)) {
6921 return new WP_Error('empty_response', 'Empty response body');
6922 }
6923
6924 // Extract and sanitize content
6925 $content = $this->mxchat_extract_main_content($html);
6926 $sanitized = $this->mxchat_sanitize_content_for_api($content);
6927
6928 if (empty($sanitized)) {
6929 // Not an error - just no content found (maybe a redirect or empty page)
6930 return false;
6931 }
6932
6933 // Submit to database with content_type
6934 $result = MxChat_Utils::submit_content_to_db(
6935 $sanitized,
6936 $url,
6937 $api_key,
6938 null,
6939 $bot_id,
6940 $content_type
6941 );
6942
6943 return $result;
6944 }
6945
6946 /**
6947 * Extract WooCommerce product content including pricing
6948 *
6949 * @param string $url The product URL
6950 * @return string|false Product content with pricing, or false if not found
6951 */
6952 private function mxchat_extract_woocommerce_product_content($url) {
6953 // Try to get product ID from URL
6954 $product_id = url_to_postid($url);
6955
6956 // If url_to_postid fails, try to extract from URL pattern
6957 if (!$product_id) {
6958 $product_slug = '';
6959
6960 // Handle pretty permalinks: /product/product-name/
6961 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
6962 $product_slug = $matches[1];
6963 }
6964
6965 if (!empty($product_slug)) {
6966 $product_post = get_page_by_path($product_slug, OBJECT, 'product');
6967 if ($product_post) {
6968 $product_id = $product_post->ID;
6969 }
6970 }
6971 }
6972
6973 if (!$product_id) {
6974 return false;
6975 }
6976
6977 // Get WooCommerce product object
6978 $product = wc_get_product($product_id);
6979
6980 if (!$product) {
6981 return false;
6982 }
6983
6984 // Build product content with pricing (similar to mxchat_store_product_embedding)
6985 $title = $product->get_name();
6986 $description = $product->get_description();
6987 $short_description = $product->get_short_description();
6988 $sku = $product->get_sku();
6989
6990 // Get pricing information
6991 $regular_price = $product->get_regular_price();
6992 $sale_price = $product->get_sale_price();
6993 $price = $product->get_price(); // Current active price
6994
6995 // Get currency symbol
6996 $currency_symbol = get_woocommerce_currency_symbol();
6997
6998 // Format content
6999 $content = $title . "\n\n";
7000
7001 if (!empty($short_description)) {
7002 $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7003 }
7004
7005 if (!empty($description)) {
7006 $content .= wp_strip_all_tags($description) . "\n\n";
7007 }
7008
7009 // Add pricing information
7010 if (!empty($regular_price)) {
7011 $content .= "Price: " . $currency_symbol . $regular_price . "\n";
7012 } elseif (!empty($price)) {
7013 $content .= "Price: " . $currency_symbol . $price . "\n";
7014 }
7015
7016 if (!empty($sale_price) && $sale_price !== $regular_price) {
7017 $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
7018 }
7019
7020 // Handle variable products - show price range
7021 if ($product->is_type('variable')) {
7022 $min_price = $product->get_variation_price('min');
7023 $max_price = $product->get_variation_price('max');
7024 if ($min_price !== $max_price) {
7025 $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
7026 }
7027 }
7028
7029 if (!empty($sku)) {
7030 $content .= "SKU: " . $sku . "\n";
7031 }
7032
7033 // Get product categories
7034 $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
7035 if (!empty($categories) && !is_wp_error($categories)) {
7036 $content .= "Categories: " . implode(', ', $categories) . "\n";
7037 }
7038
7039 // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
7040 $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7041 if (!empty($custom_tabs) && is_array($custom_tabs)) {
7042 foreach ($custom_tabs as $tab) {
7043 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7044 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7045
7046 if (!empty($tab_title) && !empty($tab_content)) {
7047 $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7048 }
7049 }
7050 }
7051
7052 // Also check for reusable/saved tabs applied to this product
7053 $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7054 if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7055 $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7056 if (!empty($saved_tabs) && is_array($saved_tabs)) {
7057 foreach ($applied_saved_tabs as $saved_tab_id) {
7058 if (isset($saved_tabs[$saved_tab_id])) {
7059 $tab = $saved_tabs[$saved_tab_id];
7060 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7061 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7062
7063 if (!empty($tab_title) && !empty($tab_content)) {
7064 $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7065 }
7066 }
7067 }
7068 }
7069 }
7070
7071 return $this->mxchat_sanitize_content_for_api($content);
7072 }
7073
7074 /**
7075 * Process a PDF page from the queue
7076 */
7077 private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
7078 $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
7079 $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
7080 $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
7081 $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
7082
7083 if (empty($pdf_path) || !file_exists($pdf_path)) {
7084 return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
7085 }
7086
7087 if ($page_number < 1) {
7088 return new WP_Error('invalid_page', 'Invalid page number');
7089 }
7090
7091 try {
7092 mxchat_load_pdf_parser();
7093 $parser = new \Smalot\PdfParser\Parser();
7094 $pdf = $parser->parseFile($pdf_path);
7095 $pages = $pdf->getPages();
7096
7097 if (!isset($pages[$page_number - 1])) {
7098 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
7099 }
7100
7101 $text = $pages[$page_number - 1]->getText();
7102
7103 if (empty($text)) {
7104 // Not an error - just an empty page
7105 return false;
7106 }
7107
7108 $sanitized = $this->mxchat_sanitize_content_for_api($text);
7109
7110 if (empty($sanitized)) {
7111 return false;
7112 }
7113
7114 // Create metadata
7115 $metadata = array(
7116 'document_type' => 'pdf',
7117 'total_pages' => $total_pages,
7118 'current_page' => $page_number,
7119 'source_url' => $pdf_url
7120 );
7121
7122 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
7123 $page_url = esc_url($pdf_url . "#page=" . $page_number);
7124
7125 // Get bot-specific API key
7126 $bot_options = $this->get_bot_options($bot_id);
7127 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7128 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7129
7130 if (strpos($selected_model, 'voyage') === 0) {
7131 $api_key = $options['voyage_api_key'] ?? '';
7132 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7133 $api_key = $options['gemini_api_key'] ?? '';
7134 } else {
7135 $api_key = $options['api_key'] ?? '';
7136 }
7137
7138 if (empty($api_key)) {
7139 return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7140 }
7141
7142 // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
7143 $result = MxChat_Utils::submit_content_to_db(
7144 $content_with_metadata,
7145 $page_url,
7146 $api_key,
7147 null,
7148 $bot_id,
7149 'pdf'
7150 );
7151
7152 return $result;
7153
7154 } catch (Exception $e) {
7155 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7156 }
7157 }
7158
7159 /**
7160 * AJAX: Get queue processing status
7161 */
7162 public function ajax_mxchat_get_queue_status() {
7163 // Verify nonce and permissions
7164 check_ajax_referer('mxchat_queue_nonce', 'nonce');
7165
7166 if (!current_user_can('manage_options')) {
7167 wp_send_json_error('Unauthorized access');
7168 }
7169
7170 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7171
7172 if (empty($queue_id)) {
7173 wp_send_json_error('Missing queue ID');
7174 }
7175
7176 global $wpdb;
7177 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7178
7179 // Get counts by status
7180 $counts = $wpdb->get_results($wpdb->prepare(
7181 "SELECT status, COUNT(*) as count
7182 FROM $table_name
7183 WHERE queue_id = %s
7184 GROUP BY status",
7185 $queue_id
7186 ), OBJECT_K);
7187
7188 $total = 0;
7189 $completed = 0;
7190 $failed = 0;
7191 $processing = 0;
7192 $pending = 0;
7193
7194 foreach ($counts as $status => $data) {
7195 $count = absint($data->count);
7196 $total += $count;
7197
7198 switch ($status) {
7199 case 'completed':
7200 $completed = $count;
7201 break;
7202 case 'failed':
7203 $failed = $count;
7204 break;
7205 case 'processing':
7206 $processing = $count;
7207 break;
7208 case 'pending':
7209 $pending = $count;
7210 break;
7211 }
7212 }
7213
7214 // Calculate percentage
7215 $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
7216
7217 // Get failed items details
7218 $failed_items = array();
7219 if ($failed > 0) {
7220 $failed_items = $wpdb->get_results($wpdb->prepare(
7221 "SELECT item_type, item_data, error_message, attempts
7222 FROM $table_name
7223 WHERE queue_id = %s
7224 AND status = 'failed'
7225 AND attempts >= max_attempts
7226 ORDER BY id DESC
7227 LIMIT 50",
7228 $queue_id
7229 ));
7230 }
7231
7232 // Get queue metadata
7233 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
7234 $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
7235
7236 // Determine if queue is complete
7237 $is_complete = ($pending === 0 && $processing === 0);
7238
7239 wp_send_json_success(array(
7240 'queue_id' => $queue_id,
7241 'queue_type' => $queue_type,
7242 'source_url' => $source_url,
7243 'total' => $total,
7244 'completed' => $completed,
7245 'failed' => $failed,
7246 'processing' => $processing,
7247 'pending' => $pending,
7248 'percentage' => $percentage,
7249 'is_complete' => $is_complete,
7250 'failed_items' => $failed_items,
7251 'status' => $is_complete ? 'complete' : 'processing'
7252 ));
7253 }
7254
7255 /**
7256 * AJAX: Clear completed queue
7257 */
7258 public function ajax_mxchat_clear_queue() {
7259 // Verify nonce and permissions
7260 check_ajax_referer('mxchat_queue_nonce', 'nonce');
7261
7262 if (!current_user_can('manage_options')) {
7263 wp_send_json_error('Unauthorized access');
7264 }
7265
7266 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7267
7268 if (empty($queue_id)) {
7269 wp_send_json_error('Missing queue ID');
7270 }
7271
7272 global $wpdb;
7273 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7274 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7275
7276 // Delete queue items
7277 $wpdb->delete(
7278 $table_name,
7279 array('queue_id' => $queue_id),
7280 array('%s')
7281 );
7282
7283 // Delete queue metadata
7284 $wpdb->delete(
7285 $meta_table,
7286 array('queue_id' => $queue_id),
7287 array('%s')
7288 );
7289
7290 wp_send_json_success(array(
7291 'message' => 'Queue cleared successfully'
7292 ));
7293 }
7294
7295 /**
7296 * AJAX: Retry failed items in queue
7297 */
7298 public function ajax_mxchat_retry_failed() {
7299 // Verify nonce and permissions
7300 check_ajax_referer('mxchat_queue_nonce', 'nonce');
7301
7302 if (!current_user_can('manage_options')) {
7303 wp_send_json_error('Unauthorized access');
7304 }
7305
7306 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7307
7308 if (empty($queue_id)) {
7309 wp_send_json_error('Missing queue ID');
7310 }
7311
7312 global $wpdb;
7313 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7314
7315 // Reset failed items to pending and reset attempt count
7316 $updated = $wpdb->update(
7317 $table_name,
7318 array(
7319 'status' => 'pending',
7320 'attempts' => 0,
7321 'error_message' => null
7322 ),
7323 array(
7324 'queue_id' => $queue_id,
7325 'status' => 'failed'
7326 ),
7327 array('%s', '%d', '%s'),
7328 array('%s', '%s')
7329 );
7330
7331 wp_send_json_success(array(
7332 'message' => 'Reset ' . $updated . ' failed items for retry',
7333 'reset_count' => $updated
7334 ));
7335 }
7336
7337
7338 public function ajax_mxchat_mark_queue_complete() {
7339 check_ajax_referer('mxchat_queue_nonce', 'nonce');
7340
7341 if (!current_user_can('manage_options')) {
7342 wp_send_json_error('Unauthorized access');
7343 }
7344
7345 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7346
7347 if (empty($queue_id)) {
7348 wp_send_json_error('Missing queue ID');
7349 }
7350
7351 // Clear active queue transients
7352 if (strpos($queue_id, 'sitemap_') === 0) {
7353 delete_transient('mxchat_active_queue_sitemap');
7354 } else if (strpos($queue_id, 'pdf_') === 0) {
7355 delete_transient('mxchat_active_queue_pdf');
7356 }
7357
7358 wp_send_json_success(array('message' => 'Queue marked as complete'));
7359 }
7360
7361
7362 // ========================================
7363 // STATIC ACCESS METHODS
7364 // ========================================
7365
7366 /**
7367 * Get singleton instance
7368 */
7369 public static function get_instance() {
7370 static $instance = null;
7371 if ($instance === null) {
7372 $instance = new self();
7373 }
7374 return $instance;
7375 }
7376 }
7377
7378 // Initialize the Knowledge manager
7379 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();