PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.7
MxChat – AI Chatbot & Content Generation for WordPress v3.1.7
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.7, at admin/class-knowledge-manager.php

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