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

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