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

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