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

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