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

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

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