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

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

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