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

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

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