PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 2.5.2
MxChat – AI Chatbot & Content Generation for WordPress v2.5.2
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
← All changes | admin/class-knowledge-manager.php +4813 -8406 3.2.112.5.2 View file →
@@ -1,8407 +1,4814 @@
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 — use browser-like headers so servers with bot protection don't block us
1218 - $response = wp_remote_get($submitted_url, array(
1219 - 'timeout' => 30,
1220 - 'sslverify' => false,
1221 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
1222 - 'headers' => array(
1223 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1224 - 'Accept-Language' => 'en-US,en;q=0.9',
1225 - ),
1226 - ));
1227 -
1228 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1229 - $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1230 - set_transient('mxchat_admin_notice_error',
1231 - sprintf(
1232 - esc_html__('Failed to fetch the URL: %s', 'mxchat'),
1233 - esc_html($error_message)
1234 - ),
1235 - 30
1236 - );
1237 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1238 - exit;
1239 - }
1240 -
1241 - $content_type = wp_remote_retrieve_header($response, 'content-type');
1242 - $body_content = wp_remote_retrieve_body($response);
1243 -
1244 - if (empty($body_content)) {
1245 - set_transient('mxchat_admin_notice_error',
1246 - esc_html__('Empty response received from URL.', 'mxchat'),
1247 - 30
1248 - );
1249 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1250 - exit;
1251 - }
1252 -
1253 - // Handle PDF URL
1254 - if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
1255 - $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
1256 -
1257 - if ($result === 'queued') {
1258 - set_transient('mxchat_admin_notice_success',
1259 - esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
1260 - 30
1261 - );
1262 - } else {
1263 - set_transient('mxchat_admin_notice_error',
1264 - esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
1265 - 30
1266 - );
1267 - }
1268 -
1269 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1270 - exit;
1271 - }
1272 -
1273 - // Handle Sitemap XML
1274 - if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
1275 - libxml_use_internal_errors(true);
1276 - $xml = simplexml_load_string($body_content);
1277 - $xml_errors = libxml_get_errors();
1278 - libxml_clear_errors();
1279 -
1280 - if ($xml === false || !empty($xml_errors)) {
1281 - set_transient('mxchat_admin_notice_error',
1282 - esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
1283 - 30
1284 - );
1285 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1286 - exit;
1287 - }
1288 -
1289 - $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
1290 -
1291 - if ($result === 'queued') {
1292 - set_transient('mxchat_admin_notice_success',
1293 - esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1294 - 30
1295 - );
1296 - } else {
1297 - set_transient('mxchat_admin_notice_error',
1298 - esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
1299 - 30
1300 - );
1301 - }
1302 -
1303 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1304 - exit;
1305 - }
1306 -
1307 - // Handle Regular URL (single page)
1308 - $page_content = $this->mxchat_extract_main_content($body_content);
1309 - $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
1310 -
1311 - //error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
1312 - //error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
1313 - //error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
1314 - //error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
1315 -
1316 - if (empty($sanitized_content)) {
1317 - set_transient('mxchat_admin_notice_error',
1318 - esc_html__('No valid content found on the provided URL.', 'mxchat'),
1319 - 30
1320 - );
1321 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1322 - exit;
1323 - }
1324 -
1325 - // For single URLs, process immediately using submit_content_to_db
1326 - // This handles chunking automatically for large content
1327 - $db_result = MxChat_Utils::submit_content_to_db(
1328 - $sanitized_content,
1329 - $submitted_url,
1330 - $api_key,
1331 - null,
1332 - $bot_id,
1333 - 'url' // content_type
1334 - );
1335 -
1336 - if (is_wp_error($db_result)) {
1337 - $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1338 - set_transient('mxchat_admin_notice_error', $error_message, 30);
1339 - } else {
1340 - $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1341 - set_transient('mxchat_admin_notice_success', $success_message, 30);
1342 - }
1343 -
1344 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1345 - exit;
1346 -}
1347 -
1348 -
1349 -public function mxchat_get_single_url_status() {
1350 - $status = get_transient('mxchat_single_url_status');
1351 - if (!$status) {
1352 - return null;
1353 - }
1354 -
1355 - // Add human-readable time
1356 - if (isset($status['timestamp'])) {
1357 - $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1358 - }
1359 -
1360 - return $status;
1361 -}
1362 -
1363 -public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1364 - if (!current_user_can('manage_options')) {
1365 - return false;
1366 - }
1367 -
1368 - try {
1369 - $sitemap_url = esc_url_raw($sitemap_url);
1370 -
1371 - if (!$xml || !is_object($xml)) {
1372 - throw new Exception(__('Invalid XML object provided', 'mxchat'));
1373 - }
1374 -
1375 - // Get bot-specific embedding API for validation
1376 - $bot_options = $this->get_bot_options($bot_id);
1377 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1378 -
1379 - // Test the embedding API before processing
1380 - $test_phrase = "Test embedding generation for MxChat";
1381 - $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1382 -
1383 - if (is_string($test_result)) {
1384 - throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1385 - }
1386 -
1387 - if (!is_array($test_result)) {
1388 - throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1389 - }
1390 -
1391 - // Extract URLs from sitemap
1392 - $urls = array();
1393 - foreach ($xml->url as $url_element) {
1394 - $url = esc_url_raw((string)$url_element->loc);
1395 - if ($url) {
1396 - $urls[] = array('url' => $url);
1397 - }
1398 - }
1399 -
1400 - $total_urls = count($urls);
1401 -
1402 - if ($total_urls < 1) {
1403 - throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1404 - }
1405 -
1406 - // Create unique queue ID
1407 - $queue_id = 'sitemap_' . md5($sitemap_url . time());
1408 -
1409 - // Add URLs to queue
1410 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1411 -
1412 - if ($queued_count === 0) {
1413 - throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1414 - }
1415 -
1416 - // Store queue metadata
1417 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1418 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1419 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1420 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1421 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1422 -
1423 - // Store queue ID in transient for status tracking
1424 - set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
1425 - set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
1426 -
1427 - return 'queued';
1428 -
1429 - } catch (Exception $e) {
1430 - $error_message = $e->getMessage();
1431 - //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
1432 -
1433 - return $error_message;
1434 - }
1435 -
1436 -}
1437 -
1438 -/**
1439 - * Remove shortcode tags but preserve the content inside them
1440 - * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
1441 - *
1442 - * @param string $content The content containing shortcodes
1443 - * @return string Content with shortcode tags removed but inner content preserved
1444 - */
1445 -private function strip_shortcode_tags_preserve_content($content) {
1446 - // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
1447 - // Content between tags is inherently preserved since only brackets are targeted
1448 - $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
1449 - return ($result !== null) ? $result : $content;
1450 -}
1451 -
1452 -public function mxchat_sanitize_content_for_api($content) {
1453 - //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
1454 -
1455 - // Remove shortcode tags but PRESERVE content inside them
1456 - $content = $this->strip_shortcode_tags_preserve_content($content);
1457 -
1458 - // Remove script, style tags, and HTML comments
1459 - $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
1460 - $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
1461 - $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
1462 -
1463 - // Remove all HTML tags and decode HTML entities
1464 - $content = wp_strip_all_tags($content);
1465 - $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
1466 -
1467 - // Normalize whitespace but preserve paragraph breaks
1468 - // First, normalize line endings to \n
1469 - $content = str_replace(["\r\n", "\r"], "\n", $content);
1470 - // Replace multiple spaces/tabs with single space, but preserve newlines
1471 - $content = preg_replace('/[ \t]+/', ' ', $content);
1472 - // Replace 3+ newlines with 2 newlines (max 2 blank lines)
1473 - $content = preg_replace('/\n{3,}/', "\n\n", $content);
1474 - // Trim each line
1475 - $lines = explode("\n", $content);
1476 - $lines = array_map('trim', $lines);
1477 - $content = implode("\n", $lines);
1478 - // Final trim
1479 - $content = trim($content);
1480 -
1481 - // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
1482 - $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
1483 -
1484 - // Remove NULL bytes which can cause database errors
1485 - $content = str_replace("\0", "", $content);
1486 -
1487 - // Ensure valid UTF-8 encoding
1488 - $content = wp_check_invalid_utf8($content);
1489 -
1490 - // Remove any extremely long strings without spaces (often garbage)
1491 - $content = preg_replace('/\S{300,}/', ' ', $content);
1492 -
1493 - // Replace problematic characters that often cause database issues
1494 - $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
1495 -
1496 - // Replace any remaining potentially problematic characters with spaces
1497 - // BUT preserve newlines by temporarily replacing them
1498 - $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
1499 - $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
1500 - $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
1501 -
1502 - // Limit to reasonable length if needed
1503 - $max_length = 65000; // Just under MySQL TEXT field limit
1504 - if (strlen($content) > $max_length) {
1505 - $content = substr($content, 0, $max_length);
1506 - }
1507 -
1508 - //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
1509 - return $content;
1510 -}
1511 -public function mxchat_extract_main_content($html) {
1512 - if (empty($html)) {
1513 - return '';
1514 - }
1515 - try {
1516 - $dom = new DOMDocument;
1517 - libxml_use_internal_errors(true); // Suppress HTML parsing errors
1518 - @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
1519 - $xpath = new DOMXPath($dom);
1520 -
1521 - // For debugging purposes
1522 - $debugEnabled = true; // Set to true to enable debugging output
1523 - $debug = function($message) use ($debugEnabled) {
1524 - if ($debugEnabled) {
1525 - //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
1526 - }
1527 - };
1528 -
1529 - // Direct targeting for Gerow theme posts
1530 - $post_text = $xpath->query('//div[contains(@class, "post-text")]');
1531 - if ($post_text && $post_text->length > 0) {
1532 - $debug("Found post-text directly");
1533 - $content = '';
1534 - foreach ($post_text as $node) {
1535 - $content .= $dom->saveHTML($node);
1536 - }
1537 - if (!empty($content)) {
1538 - $debug("Returning post-text content");
1539 - return $content;
1540 - }
1541 - }
1542 -
1543 - // Try to get the blog details content which contains the post-text
1544 - $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
1545 - if ($blog_details && $blog_details->length > 0) {
1546 - $debug("Found blog-details-content");
1547 - $content = '';
1548 - foreach ($blog_details as $node) {
1549 - $content .= $dom->saveHTML($node);
1550 - }
1551 - if (!empty($content)) {
1552 - $debug("Returning blog-details-content");
1553 - return $content;
1554 - }
1555 - }
1556 -
1557 - // Try to get the article which contains the blog details
1558 - $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
1559 - if ($article && $article->length > 0) {
1560 - $debug("Found article with blog-details-wrap");
1561 - $content = '';
1562 - foreach ($article as $node) {
1563 - $content .= $dom->saveHTML($node);
1564 - }
1565 - if (!empty($content)) {
1566 - $debug("Returning article content");
1567 - return $content;
1568 - }
1569 - }
1570 -
1571 - // Try even broader with the blog-item-wrap
1572 - $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
1573 - if ($blog_item && $blog_item->length > 0) {
1574 - $debug("Found blog-item-wrap");
1575 - $content = '';
1576 - foreach ($blog_item as $node) {
1577 - $content .= $dom->saveHTML($node);
1578 - }
1579 - if (!empty($content)) {
1580 - $debug("Returning blog-item-wrap content");
1581 - return $content;
1582 - }
1583 - }
1584 -
1585 - // Specific Gerow theme path
1586 - $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
1587 - if ($gerow_path && $gerow_path->length > 0) {
1588 - $debug("Found Gerow theme path to post-text");
1589 - $content = '';
1590 - foreach ($gerow_path as $node) {
1591 - $content .= $dom->saveHTML($node);
1592 - }
1593 - if (!empty($content)) {
1594 - $debug("Returning Gerow post-text content");
1595 - return $content;
1596 - }
1597 - }
1598 -
1599 - // Generic blog post selectors
1600 - $selectors = [
1601 - // Blog post specific selectors
1602 - '//div[contains(@class, "post-text")]',
1603 - '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
1604 - '//div[contains(@class, "blog-details-content")]',
1605 - '//article[contains(@class, "blog-details-wrap")]',
1606 - '//div[contains(@class, "entry-content")]',
1607 - '//div[contains(@class, "blog-content")]',
1608 - '//div[contains(@class, "blog-item-wrap")]',
1609 -
1610 - // More general content selectors
1611 - '//div[contains(@class, "page__content")]',
1612 - '//div[contains(@class, "elementor-widget-container")]',
1613 - '//div[contains(@class, "elementor-text-editor")]',
1614 - '//div[contains(@class, "elementor-widget-text-editor")]',
1615 - '//*[contains(@class, "entry-content")]',
1616 - '//*[contains(@class, "post-content")]',
1617 - '//*[contains(@class, "article-content")]',
1618 - '//*[@id="content"]',
1619 - '//*[@id="main-content"]',
1620 - '//section[contains(@class, "blog-area")]',
1621 - '//article',
1622 - '//main',
1623 - '//div[contains(@class, "content")]'
1624 - ];
1625 -
1626 - // First handle Elementor content - get only leaf widget containers to avoid duplicates
1627 - $debug("Checking for Elementor content");
1628 - // Get widget containers that are direct children of widgets (not nested inside other widget containers)
1629 - $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
1630 - if ($elementor_widgets && $elementor_widgets->length > 0) {
1631 - $debug("Found Elementor widgets");
1632 - $seen_content = array(); // Track seen content to avoid duplicates
1633 - $combined_content = '';
1634 - foreach ($elementor_widgets as $widget) {
1635 - $widget_content = $dom->saveHTML($widget);
1636 - if (!empty($widget_content)) {
1637 - // Create a hash of the content to detect duplicates
1638 - $content_hash = md5($widget_content);
1639 - if (!isset($seen_content[$content_hash])) {
1640 - $seen_content[$content_hash] = true;
1641 - $combined_content .= $widget_content;
1642 - }
1643 - }
1644 - }
1645 - if (!empty($combined_content)) {
1646 - $debug("Returning Elementor content");
1647 - return $combined_content;
1648 - }
1649 - }
1650 -
1651 - // Try standard selectors one by one
1652 - foreach ($selectors as $selector) {
1653 - $debug("Trying selector: " . $selector);
1654 - $nodes = $xpath->query($selector);
1655 - if ($nodes && $nodes->length > 0) {
1656 - $debug("Found " . $nodes->length . " matches for selector: " . $selector);
1657 - // Only take the FIRST matching node to avoid duplicate content
1658 - // (pages often have nested or multiple containers with same class)
1659 - $content = $dom->saveHTML($nodes->item(0));
1660 - if (!empty($content)) {
1661 - $debug("Returning content from selector: " . $selector . " (first match only)");
1662 - return $content;
1663 - }
1664 - }
1665 - }
1666 -
1667 - // Manual regex fallback for post-text if DOM methods fail
1668 - $debug("Trying regex fallback");
1669 - if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
1670 - $debug("Found post-text via regex");
1671 - return '<div class="post-text">' . $matches[1] . '</div>';
1672 - }
1673 -
1674 - // Try to extract the blog section as a whole
1675 - $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
1676 - if ($blog_section && $blog_section->length > 0) {
1677 - $debug("Found blog-area section");
1678 - $content = '';
1679 - foreach ($blog_section as $node) {
1680 - $content .= $dom->saveHTML($node);
1681 - }
1682 - if (!empty($content)) {
1683 - $debug("Returning blog-area section content");
1684 - return $content;
1685 - }
1686 - }
1687 -
1688 - // Generic container selectors for non-CMS sites (like .asp pages)
1689 - $debug("Trying generic container selectors");
1690 - $generic_selectors = [
1691 - '//div[@id="main"]',
1692 - '//div[@id="wrapper"]',
1693 - '//div[@id="page"]',
1694 - '//div[@id="site-content"]',
1695 - '//div[contains(@class, "main-content")]',
1696 - '//div[contains(@class, "page-content")]',
1697 - '//div[contains(@class, "site-content")]',
1698 - ];
1699 -
1700 - foreach ($generic_selectors as $selector) {
1701 - $debug("Trying generic selector: " . $selector);
1702 - $nodes = $xpath->query($selector);
1703 - if ($nodes && $nodes->length > 0) {
1704 - $content = $dom->saveHTML($nodes->item(0));
1705 - if (!empty($content)) {
1706 - $debug("Returning content from generic selector: " . $selector);
1707 - return $content;
1708 - }
1709 - }
1710 - }
1711 -
1712 - // Paragraph-based content detection - find regions with substantial text
1713 - $debug("Trying paragraph-based content detection");
1714 - $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
1715 - if ($paragraphs && $paragraphs->length >= 3) {
1716 - $debug("Found " . $paragraphs->length . " substantial paragraphs");
1717 - // Collect all substantial paragraphs and their content
1718 - $paragraph_content = '';
1719 - foreach ($paragraphs as $p) {
1720 - $paragraph_content .= $dom->saveHTML($p) . "\n";
1721 - }
1722 - if (!empty($paragraph_content)) {
1723 - $debug("Returning paragraph-based content");
1724 - return $paragraph_content;
1725 - }
1726 - }
1727 -
1728 - // Improved body fallback - strip nav/header/footer elements first
1729 - $debug("Using improved body fallback");
1730 - $body = $dom->getElementsByTagName('body');
1731 - if ($body->length > 0) {
1732 - // Clone the body to avoid modifying the original DOM
1733 - $body_clone = $body->item(0)->cloneNode(true);
1734 -
1735 - // Remove common non-content elements by tag name
1736 - $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
1737 - foreach ($remove_tags as $tag) {
1738 - $elements = $body_clone->getElementsByTagName($tag);
1739 - // Iterate backwards to safely remove elements
1740 - for ($i = $elements->length - 1; $i >= 0; $i--) {
1741 - $el = $elements->item($i);
1742 - if ($el && $el->parentNode) {
1743 - $el->parentNode->removeChild($el);
1744 - }
1745 - }
1746 - }
1747 -
1748 - // Remove elements with common non-content class names using XPath on the cloned body
1749 - $temp_dom = new DOMDocument();
1750 - @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
1751 - $temp_xpath = new DOMXPath($temp_dom);
1752 -
1753 - $remove_class_patterns = [
1754 - '//*[contains(@class, "nav")]',
1755 - '//*[contains(@class, "menu")]',
1756 - '//*[contains(@class, "sidebar")]',
1757 - '//*[contains(@class, "footer")]',
1758 - '//*[contains(@class, "header")]',
1759 - '//*[contains(@id, "nav")]',
1760 - '//*[contains(@id, "menu")]',
1761 - '//*[contains(@id, "sidebar")]',
1762 - '//*[contains(@id, "footer")]',
1763 - '//*[contains(@id, "header")]',
1764 - ];
1765 -
1766 - foreach ($remove_class_patterns as $pattern) {
1767 - $elements = $temp_xpath->query($pattern);
1768 - if ($elements) {
1769 - for ($i = $elements->length - 1; $i >= 0; $i--) {
1770 - $el = $elements->item($i);
1771 - if ($el && $el->parentNode) {
1772 - $el->parentNode->removeChild($el);
1773 - }
1774 - }
1775 - }
1776 - }
1777 -
1778 - $cleaned_content = $temp_dom->saveHTML();
1779 - if (!empty($cleaned_content)) {
1780 - $debug("Returning cleaned body content");
1781 - return $cleaned_content;
1782 - }
1783 - }
1784 -
1785 - // Last resort: return the original HTML
1786 - $debug("Returning original HTML");
1787 - return $html;
1788 - } catch (Exception $e) {
1789 - //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
1790 - return $html; // Return original HTML if parsing fails
1791 - } finally {
1792 - libxml_clear_errors();
1793 - }
1794 -}
1795 -public function mxchat_get_sitemap_processing_status($sitemap_url) {
1796 - $sitemap_url = esc_url_raw($sitemap_url);
1797 - $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
1798 - $status = get_transient($status_key);
1799 -
1800 - if (!$status || !is_array($status)) {
1801 - return false;
1802 - }
1803 -
1804 - // Auto-complete check: if all URLs are processed but status isn't complete
1805 - if (isset($status['processed_urls']) && isset($status['total_urls']) &&
1806 - $status['processed_urls'] >= $status['total_urls'] &&
1807 - isset($status['status']) && $status['status'] !== 'complete' &&
1808 - $status['status'] !== 'error') {
1809 -
1810 - // Mark as complete
1811 - $status['status'] = 'complete';
1812 - $status['processed_urls'] = $status['total_urls']; // Ensure exact match
1813 -
1814 - // Update the transient with the corrected status
1815 - set_transient($status_key, $status, DAY_IN_SECONDS);
1816 - }
1817 -
1818 - return array(
1819 - 'total_urls' => absint($status['total_urls']),
1820 - 'processed_urls' => absint($status['processed_urls']),
1821 - 'failed_urls' => absint($status['failed_urls'] ?? 0),
1822 - 'percentage' => ($status['total_urls'] > 0)
1823 - ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
1824 - : 0,
1825 - 'status' => sanitize_text_field($status['status']),
1826 - 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1827 - 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
1828 - 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
1829 - 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
1830 - );
1831 -}
1832 -
1833 -public function mxchat_ajax_get_status_updates() {
1834 - try {
1835 - // Verify the request
1836 - check_ajax_referer('mxchat_status_nonce', 'nonce');
1837 -
1838 - // Get active queue IDs
1839 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1840 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1841 -
1842 - $sitemap_status = false;
1843 - $pdf_status = false;
1844 -
1845 - // Get sitemap queue status
1846 - if ($sitemap_queue_id) {
1847 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1848 - }
1849 -
1850 - // Get PDF queue status
1851 - if ($pdf_queue_id) {
1852 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1853 - }
1854 -
1855 - $is_active_processing =
1856 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1857 - ($pdf_status && $pdf_status['status'] === 'processing');
1858 -
1859 - // Return JSON response with the status data
1860 - wp_send_json(array(
1861 - 'pdf_status' => $pdf_status,
1862 - 'sitemap_status' => $sitemap_status,
1863 - 'is_processing' => $is_active_processing,
1864 - 'sitemap_queue_id' => $sitemap_queue_id,
1865 - 'pdf_queue_id' => $pdf_queue_id
1866 - ));
1867 -
1868 - } catch (Exception $e) {
1869 - //error_log('MxChat Status Update Error: ' . $e->getMessage());
1870 -
1871 - wp_send_json_error(array(
1872 - 'message' => 'Error getting status updates: ' . $e->getMessage(),
1873 - 'status' => 'error'
1874 - ));
1875 - }
1876 -}
1877 -
1878 -/**
1879 - * Helper function to get queue status data
1880 - */
1881 -private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
1882 - global $wpdb;
1883 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
1884 -
1885 - // Get counts by status
1886 - $counts = $wpdb->get_results($wpdb->prepare(
1887 - "SELECT status, COUNT(*) as count
1888 - FROM $table_name
1889 - WHERE queue_id = %s
1890 - GROUP BY status",
1891 - $queue_id
1892 - ), OBJECT_K);
1893 -
1894 - $total = 0;
1895 - $completed = 0;
1896 - $failed = 0;
1897 - $processing = 0;
1898 - $pending = 0;
1899 -
1900 - foreach ($counts as $status => $data) {
1901 - $count = absint($data->count);
1902 - $total += $count;
1903 -
1904 - switch ($status) {
1905 - case 'completed':
1906 - $completed = $count;
1907 - break;
1908 - case 'failed':
1909 - $failed = $count;
1910 - break;
1911 - case 'processing':
1912 - $processing = $count;
1913 - break;
1914 - case 'pending':
1915 - $pending = $count;
1916 - break;
1917 - }
1918 - }
1919 -
1920 - if ($total === 0) {
1921 - return false;
1922 - }
1923 -
1924 - // Calculate percentage
1925 - $percentage = round((($completed + $failed) / $total) * 100);
1926 -
1927 - // Get failed items details (limit to 50)
1928 - $failed_items = array();
1929 - if ($failed > 0) {
1930 - $failed_results = $wpdb->get_results($wpdb->prepare(
1931 - "SELECT item_type, item_data, error_message, attempts, completed_at
1932 - FROM $table_name
1933 - WHERE queue_id = %s
1934 - AND status = 'failed'
1935 - AND attempts >= max_attempts
1936 - ORDER BY id DESC
1937 - LIMIT 50",
1938 - $queue_id
1939 - ));
1940 -
1941 - foreach ($failed_results as $item) {
1942 - $data = json_decode($item->item_data, true);
1943 - $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
1944 -
1945 - $failed_items[] = array(
1946 - 'url' => $url,
1947 - 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
1948 - 'error' => $item->error_message,
1949 - 'retries' => $item->attempts,
1950 - 'time' => strtotime($item->completed_at)
1951 - );
1952 - }
1953 - }
1954 -
1955 - // Get queue metadata
1956 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
1957 -
1958 - // Determine if queue is complete
1959 - $is_complete = ($pending === 0 && $processing === 0);
1960 -
1961 - // Get last update time
1962 - $last_update = $wpdb->get_var($wpdb->prepare(
1963 - "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
1964 - FROM $table_name
1965 - WHERE queue_id = %s",
1966 - $queue_id
1967 - ));
1968 -
1969 - $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
1970 -
1971 - // Format based on type
1972 - if ($type === 'pdf') {
1973 - return array(
1974 - 'total_pages' => $total,
1975 - 'processed_pages' => $completed + $failed,
1976 - 'failed_pages' => $failed,
1977 - 'percentage' => $percentage,
1978 - 'status' => $is_complete ? 'complete' : 'processing',
1979 - 'last_update' => $last_update_text,
1980 - 'failed_pages_list' => $failed_items,
1981 - 'pdf_url' => $source_url,
1982 - 'queue_id' => $queue_id
1983 - );
1984 - } else {
1985 - return array(
1986 - 'total_urls' => $total,
1987 - 'processed_urls' => $completed + $failed,
1988 - 'failed_urls' => $failed,
1989 - 'percentage' => $percentage,
1990 - 'status' => $is_complete ? 'complete' : 'processing',
1991 - 'last_update' => $last_update_text,
1992 - 'failed_urls_list' => $failed_items,
1993 - 'sitemap_url' => $source_url,
1994 - 'queue_id' => $queue_id
1995 - );
1996 - }
1997 -}
1998 -
1999 -/**
2000 - * Public method to get processing status for both sitemap and PDF queues
2001 - * Used by admin pages to display processing status
2002 - *
2003 - * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
2004 - */
2005 -public function mxchat_get_processing_statuses() {
2006 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2007 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2008 -
2009 - $sitemap_status = false;
2010 - $pdf_status = false;
2011 -
2012 - if ($sitemap_queue_id) {
2013 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2014 - }
2015 -
2016 - if ($pdf_queue_id) {
2017 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2018 - }
2019 -
2020 - $is_processing =
2021 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2022 - ($pdf_status && $pdf_status['status'] === 'processing');
2023 -
2024 - return array(
2025 - 'sitemap_status' => $sitemap_status,
2026 - 'pdf_status' => $pdf_status,
2027 - 'is_processing' => $is_processing
2028 - );
2029 -}
2030 -
2031 -/**
2032 - * AJAX handler to get recent knowledge entries for real-time table updates
2033 - * UPDATED: Now supports both WordPress DB and Pinecone data sources
2034 - */
2035 -public function ajax_mxchat_get_recent_entries() {
2036 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2037 -
2038 - if (!current_user_can('manage_options')) {
2039 - wp_send_json_error(array('message' => 'Unauthorized'));
2040 - return;
2041 - }
2042 -
2043 - global $wpdb;
2044 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2045 -
2046 - // Get parameters
2047 - $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
2048 - $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
2049 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2050 -
2051 - // Check if Pinecone is enabled for this bot
2052 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2053 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2054 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2055 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2056 -
2057 - if ($use_pinecone && $has_pinecone_api) {
2058 - // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
2059 - // Use mxchat_fetch_pinecone_records which returns total_unique_entries
2060 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
2061 - $total_count = $records['total'] ?? 0;
2062 -
2063 - // For Pinecone, we don't return individual entries during polling
2064 - // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
2065 - // We just return the updated count
2066 - wp_send_json_success(array(
2067 - 'entries' => array(),
2068 - 'total_count' => absint($total_count),
2069 - 'max_id' => $last_id,
2070 - 'data_source' => 'pinecone'
2071 - ));
2072 - return;
2073 - }
2074 -
2075 - // WORDPRESS DB DATA SOURCE
2076 - // Build query to get entries newer than last_id
2077 - $where_clauses = array('1=1');
2078 - $where_values = array();
2079 -
2080 - if ($last_id > 0) {
2081 - $where_clauses[] = 'id > %d';
2082 - $where_values[] = $last_id;
2083 - }
2084 -
2085 - // Note: WordPress DB table doesn't have bot_id column
2086 - // Multi-bot filtering is handled via Pinecone namespaces
2087 -
2088 - $where_sql = implode(' AND ', $where_clauses);
2089 -
2090 - // Get recent entries
2091 - $query = "SELECT id, article_content, source_url, timestamp
2092 - FROM $table_name
2093 - WHERE $where_sql
2094 - ORDER BY id DESC
2095 - LIMIT %d";
2096 -
2097 - $where_values[] = $limit;
2098 -
2099 - $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
2100 -
2101 - // Get total count of GROUPED entries (by source_url) - matches pagination display
2102 - // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
2103 - $total_count = $wpdb->get_var(
2104 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2105 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2106 - );
2107 -
2108 - // Format entries for response
2109 - $formatted_entries = array();
2110 - $preview_length = 150;
2111 - foreach ($entries as $entry) {
2112 - // Parse chunk metadata using the proper chunker method (same as initial page load)
2113 - if (class_exists('MxChat_Chunker')) {
2114 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
2115 - $display_content = $chunk_meta['text'];
2116 - $chunk_metadata = $chunk_meta['metadata'];
2117 - } else {
2118 - $display_content = $entry->article_content;
2119 - $chunk_metadata = array();
2120 - }
2121 -
2122 - $content_preview = mb_strlen($display_content) > $preview_length
2123 - ? mb_substr($display_content, 0, $preview_length) . '...'
2124 - : $display_content;
2125 -
2126 - $formatted_entries[] = array(
2127 - 'id' => $entry->id,
2128 - 'preview' => esc_html($content_preview),
2129 - 'full_content' => wp_kses_post(wpautop($display_content)),
2130 - 'content_length' => mb_strlen($display_content),
2131 - 'preview_length' => $preview_length,
2132 - 'source_url' => $entry->source_url,
2133 - 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2134 - 'chunk_metadata' => $chunk_metadata,
2135 - 'bot_id' => $entry->bot_id ?? 'default',
2136 - 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2137 - 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2138 - );
2139 - }
2140 -
2141 - wp_send_json_success(array(
2142 - 'entries' => $formatted_entries,
2143 - 'total_count' => absint($total_count),
2144 - 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
2145 - 'data_source' => 'wordpress'
2146 - ));
2147 -}
2148 -
2149 -/**
2150 - * Get Pinecone total count from stats API
2151 - * Helper function for ajax_mxchat_get_recent_entries
2152 - */
2153 -private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
2154 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2155 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2156 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2157 -
2158 - if (empty($api_key) || empty($host)) {
2159 - return 0;
2160 - }
2161 -
2162 - try {
2163 - $stats_url = "https://{$host}/describe_index_stats";
2164 -
2165 - $response = wp_remote_post($stats_url, array(
2166 - 'headers' => array(
2167 - 'Api-Key' => $api_key,
2168 - 'Content-Type' => 'application/json'
2169 - ),
2170 - 'body' => '{}',
2171 - 'timeout' => 10
2172 - ));
2173 -
2174 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2175 - $body = wp_remote_retrieve_body($response);
2176 - $stats_data = json_decode($body, true);
2177 -
2178 - // If namespace is specified, get count from that specific namespace
2179 - if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
2180 - return intval($stats_data['namespaces'][$namespace]['vectorCount']);
2181 - }
2182 -
2183 - // If no namespace specified or namespace not found in response, use total
2184 - return intval($stats_data['totalVectorCount'] ?? 0);
2185 - }
2186 -
2187 - return 0;
2188 -
2189 - } catch (Exception $e) {
2190 - return 0;
2191 - }
2192 -}
2193 -
2194 -/**
2195 - * AJAX handler to refresh Pinecone entries table via AJAX
2196 - * Returns the table HTML for updating the UI without a full page reload
2197 - */
2198 -public function ajax_mxchat_refresh_pinecone_entries() {
2199 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2200 -
2201 - if (!current_user_can('manage_options')) {
2202 - wp_send_json_error(array('message' => 'Unauthorized'));
2203 - return;
2204 - }
2205 -
2206 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2207 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2208 - $per_page = 25;
2209 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2210 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2211 -
2212 - // Get Pinecone manager and options
2213 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2214 - if (!$pinecone_manager) {
2215 - wp_send_json_error(array('message' => 'Pinecone manager not available'));
2216 - return;
2217 - }
2218 -
2219 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
2220 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2221 - $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2222 -
2223 - if (!$use_pinecone || empty($pinecone_api_key)) {
2224 - wp_send_json_error(array('message' => 'Pinecone not configured'));
2225 - return;
2226 - }
2227 -
2228 - // Fetch records from Pinecone
2229 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
2230 - $prompts = $records['data'] ?? array();
2231 - $total_records = $records['total'] ?? 0;
2232 -
2233 - // Preprocess Pinecone records — set chunk_metadata and display_content
2234 - // (matches admin-knowledge-page.php preprocessing)
2235 - foreach ($prompts as $prompt) {
2236 - if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
2237 - $prompt->chunk_metadata = array(
2238 - 'chunk_index' => intval($prompt->chunk_index),
2239 - 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
2240 - 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
2241 - 'source_url' => $prompt->source_url ?? ''
2242 - );
2243 - $prompt->display_content = $prompt->article_content;
2244 - } else {
2245 - $prompt->chunk_metadata = array();
2246 - $prompt->display_content = $prompt->article_content ?? '';
2247 - }
2248 - }
2249 -
2250 - // Group prompts by source_url
2251 - $grouped_prompts = array();
2252 - foreach ($prompts as $prompt) {
2253 - $source_url = '';
2254 - if (!empty($prompt->chunk_metadata['source_url'])) {
2255 - $source_url = $prompt->chunk_metadata['source_url'];
2256 - } elseif (!empty($prompt->source_url)) {
2257 - $source_url = $prompt->source_url;
2258 - }
2259 -
2260 - if (!empty($source_url)) {
2261 - if (!isset($grouped_prompts[$source_url])) {
2262 - $grouped_prompts[$source_url] = array();
2263 - }
2264 - $grouped_prompts[$source_url][] = $prompt;
2265 - } else {
2266 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2267 - }
2268 - }
2269 -
2270 - // Sort each group by chunk_index
2271 - foreach ($grouped_prompts as $source_url => &$group) {
2272 - usort($group, function($a, $b) {
2273 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2274 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2275 - return $index_a - $index_b;
2276 - });
2277 - }
2278 - unset($group);
2279 -
2280 - // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
2281 - ob_start();
2282 - $display_index = 0;
2283 - $current_page = $page;
2284 - $data_source = 'pinecone';
2285 - $current_bot_id = $bot_id;
2286 - $preview_length = 150;
2287 -
2288 - if (empty($grouped_prompts)) {
2289 - echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2290 - esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
2291 - echo '</td></tr>';
2292 - } else {
2293 - foreach ($grouped_prompts as $source_url => $group) {
2294 - $chunk_count = count($group);
2295 - $first_prompt = $group[0];
2296 - $display_index++;
2297 -
2298 - if ($chunk_count > 1) {
2299 - // Multiple chunks - show grouped row with expand button
2300 - $group_id = 'group-' . md5($source_url);
2301 - ?>
2302 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2303 - class="mxchat-chunk-group-header"
2304 - data-source="<?php echo esc_attr($data_source); ?>"
2305 - data-group-id="<?php echo esc_attr($group_id); ?>"
2306 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2307 - <td style="padding: 12px 16px; text-align: center;">
2308 - <input type="checkbox"
2309 - class="mxchat-entry-checkbox"
2310 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2311 - data-source="<?php echo esc_attr($data_source); ?>"
2312 - data-source-url="<?php echo esc_attr($source_url); ?>"
2313 - data-is-group="true"
2314 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2315 - </td>
2316 - <td style="padding: 12px 16px; font-size: 13px;">
2317 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2318 - </td>
2319 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2320 - <div class="mxchat-chunk-group-info">
2321 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2322 - <span class="dashicons dashicons-arrow-right-alt2"></span>
2323 - </button>
2324 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2325 - <span class="mxchat-chunk-preview">
2326 - <?php
2327 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
2328 - $content_preview = mb_substr($parent_content, 0, 100);
2329 - echo esc_html($content_preview . '...');
2330 - ?>
2331 - </span>
2332 - </div>
2333 - </td>
2334 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2335 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2336 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2337 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2338 - <?php esc_html_e('View Source', 'mxchat'); ?>
2339 - </a>
2340 - <?php else : ?>
2341 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2342 - <?php endif; ?>
2343 - </td>
2344 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2345 - <?php if ($data_source !== 'pinecone') : ?>
2346 - <button type="button"
2347 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2348 - data-source-url="<?php echo esc_attr($source_url); ?>"
2349 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2350 - data-data-source="<?php echo esc_attr($data_source); ?>"
2351 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2352 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2353 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2354 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2355 - </button>
2356 - <?php endif; ?>
2357 - <button type="button"
2358 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2359 - data-source-url="<?php echo esc_attr($source_url); ?>"
2360 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2361 - data-data-source="<?php echo esc_attr($data_source); ?>"
2362 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2363 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2364 - style="color: var(--mxch-error);"
2365 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2366 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2367 - </button>
2368 - </td>
2369 - </tr>
2370 - <?php
2371 - // Render hidden chunk rows
2372 - foreach ($group as $chunk_index => $chunk) {
2373 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2374 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2375 - $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
2376 - $content_preview = mb_strlen($content) > $preview_length
2377 - ? mb_substr($content, 0, $preview_length) . '...'
2378 - : $content;
2379 - ?>
2380 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2381 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2382 - data-source="<?php echo esc_attr($data_source); ?>"
2383 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2384 - <td style="padding: 12px 16px; text-align: center;">
2385 - <!-- Checkbox column placeholder for chunks (managed by group) -->
2386 - </td>
2387 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2388 - <!-- Hidden ID column for chunks -->
2389 - </td>
2390 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2391 - <div class="mxchat-accordion-wrapper">
2392 - <div class="mxchat-content-preview">
2393 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2394 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2395 - </span>
2396 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2397 - <?php if (mb_strlen($content) > $preview_length) : ?>
2398 - <button class="mxchat-expand-toggle" type="button">
2399 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2400 - </button>
2401 - <?php endif; ?>
2402 - </div>
2403 - <div class="mxchat-content-full" style="display: none;">
2404 - <div class="content-view">
2405 - <?php
2406 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2407 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2408 - echo wp_kses_post(wpautop($content));
2409 - echo '</div>';
2410 - } else {
2411 - echo wp_kses_post(wpautop($content));
2412 - }
2413 - ?>
2414 - </div>
2415 - </div>
2416 - </div>
2417 - </td>
2418 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2419 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2420 - </td>
2421 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2422 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2423 - </td>
2424 - </tr>
2425 - <?php
2426 - }
2427 - } else {
2428 - // Single entry - display normally with accordion
2429 - $prompt = $first_prompt;
2430 - $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
2431 - $content_preview = mb_strlen($content) > $preview_length
2432 - ? mb_substr($content, 0, $preview_length) . '...'
2433 - : $content;
2434 - ?>
2435 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2436 - data-source="<?php echo esc_attr($data_source); ?>"
2437 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2438 - <td style="padding: 12px 16px; text-align: center;">
2439 - <input type="checkbox"
2440 - class="mxchat-entry-checkbox"
2441 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2442 - data-source="<?php echo esc_attr($data_source); ?>"
2443 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
2444 - data-is-group="false"
2445 - data-chunk-count="1">
2446 - </td>
2447 - <td style="padding: 12px 16px; font-size: 13px;">
2448 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2449 - </td>
2450 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2451 - <div class="mxchat-accordion-wrapper">
2452 - <div class="mxchat-content-preview">
2453 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2454 - <?php if (mb_strlen($content) > $preview_length) : ?>
2455 - <button class="mxchat-expand-toggle" type="button">
2456 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2457 - </button>
2458 - <?php endif; ?>
2459 - </div>
2460 - <div class="mxchat-content-full" style="display: none;">
2461 - <div class="content-view">
2462 - <?php
2463 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2464 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2465 - echo wp_kses_post(wpautop($content));
2466 - echo '</div>';
2467 - } else {
2468 - echo wp_kses_post(wpautop($content));
2469 - }
2470 - ?>
2471 - </div>
2472 - </div>
2473 - </div>
2474 - </td>
2475 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2476 - <?php
2477 - $actual_source = $source_url;
2478 - if (strpos($source_url, '_ungrouped_') === 0) {
2479 - $actual_source = $prompt->source_url ?? '';
2480 - }
2481 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2482 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2483 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2484 - <?php esc_html_e('View', 'mxchat'); ?>
2485 - </a>
2486 - <?php else : ?>
2487 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2488 - <?php endif; ?>
2489 - </td>
2490 - <td style="padding: 12px 16px;">
2491 - <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);">
2492 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2493 - </button>
2494 - </td>
2495 - </tr>
2496 - <?php
2497 - }
2498 - }
2499 - }
2500 - $html = ob_get_clean();
2501 -
2502 - // Generate pagination HTML for Pinecone
2503 - $total_pages = ceil($total_records / $per_page);
2504 - $pagination_html = '';
2505 - if ($total_pages > 1) {
2506 - $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) . '">';
2507 -
2508 - // Previous button
2509 - if ($page > 1) {
2510 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2511 - }
2512 -
2513 - // Page numbers
2514 - $start_page = max(1, $page - 2);
2515 - $end_page = min($total_pages, $page + 2);
2516 -
2517 - if ($start_page > 1) {
2518 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2519 - if ($start_page > 2) {
2520 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2521 - }
2522 - }
2523 -
2524 - for ($i = $start_page; $i <= $end_page; $i++) {
2525 - if ($i == $page) {
2526 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2527 - } else {
2528 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2529 - }
2530 - }
2531 -
2532 - if ($end_page < $total_pages) {
2533 - if ($end_page < $total_pages - 1) {
2534 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2535 - }
2536 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2537 - }
2538 -
2539 - // Next button
2540 - if ($page < $total_pages) {
2541 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2542 - }
2543 -
2544 - $pagination_html .= '</div>';
2545 - }
2546 -
2547 - wp_send_json_success(array(
2548 - 'html' => $html,
2549 - 'pagination_html' => $pagination_html,
2550 - 'total_count' => $total_records,
2551 - 'total_pages' => $total_pages,
2552 - 'page' => $page,
2553 - 'per_page' => $per_page,
2554 - 'data_source' => 'pinecone'
2555 - ));
2556 -}
2557 -
2558 -/**
2559 - * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
2560 - * Returns paginated entries without requiring a full page reload
2561 - */
2562 -public function ajax_mxchat_paginate_entries() {
2563 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2564 -
2565 - if (!current_user_can('manage_options')) {
2566 - wp_send_json_error(array('message' => 'Unauthorized'));
2567 - return;
2568 - }
2569 -
2570 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2571 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2572 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2573 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2574 - $per_page = 25;
2575 -
2576 - // Check if Pinecone is enabled for this bot
2577 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2578 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2579 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2580 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2581 -
2582 - if ($use_pinecone && $has_pinecone_api) {
2583 - // Delegate to Pinecone pagination handler (pass search params)
2584 - $_POST['page'] = $page;
2585 - $_POST['search'] = $search_query;
2586 - $_POST['content_type'] = $content_type_filter;
2587 - $this->ajax_mxchat_refresh_pinecone_entries();
2588 - return;
2589 - }
2590 -
2591 - // WordPress DB pagination - MUST match initial page load logic exactly
2592 - global $wpdb;
2593 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2594 - $offset = ($page - 1) * $per_page;
2595 -
2596 - // Build WHERE clause for search and content type filtering
2597 - $where_clauses = array();
2598 - $where_values = array();
2599 -
2600 - if ($search_query) {
2601 - $where_clauses[] = "article_content LIKE %s";
2602 - $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
2603 - }
2604 -
2605 - if ($content_type_filter) {
2606 - switch ($content_type_filter) {
2607 - case 'manual':
2608 - $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
2609 - break;
2610 - case 'pdf':
2611 - $where_clauses[] = "source_url LIKE '%.pdf'";
2612 - break;
2613 - case 'url':
2614 - $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
2615 - break;
2616 - }
2617 - }
2618 -
2619 - $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
2620 -
2621 - // Count grouped entries with filters applied
2622 - if (!empty($where_values)) {
2623 - $count_args = array_merge($where_values, $where_values);
2624 - $count_query = $wpdb->prepare(
2625 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2626 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
2627 - ...$count_args
2628 - );
2629 - $total_records = $wpdb->get_var($count_query);
2630 - } else if (!empty($where_sql)) {
2631 - // Content type filter only (no search), no prepared values needed
2632 - $total_records = $wpdb->get_var(
2633 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2634 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
2635 - );
2636 - } else {
2637 - // No filters
2638 - $total_records = $wpdb->get_var(
2639 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2640 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2641 - );
2642 - }
2643 - $total_pages = ceil($total_records / $per_page);
2644 -
2645 - // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
2646 - if (!empty($where_values)) {
2647 - $query_args = array_merge($where_values, array($per_page, $offset));
2648 - $urls_query = $wpdb->prepare(
2649 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2650 - {$where_sql}
2651 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2652 - ...$query_args
2653 - );
2654 - } else if (!empty($where_sql)) {
2655 - $urls_query = $wpdb->prepare(
2656 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2657 - {$where_sql}
2658 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2659 - $per_page, $offset
2660 - );
2661 - } else {
2662 - $urls_query = $wpdb->prepare(
2663 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2664 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2665 - $per_page, $offset
2666 - );
2667 - }
2668 - $page_urls = $wpdb->get_results($urls_query);
2669 -
2670 - // Step 2: Build list of source_urls to fetch
2671 - $url_list = array();
2672 - $url_order_map = array();
2673 - $order_index = 0;
2674 - foreach ($page_urls as $url_row) {
2675 - $url = $url_row->source_url;
2676 - $url_list[] = $url;
2677 - $url_order_map[$url] = $order_index++;
2678 - }
2679 -
2680 - // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
2681 - $prompts = array();
2682 - if (!empty($url_list)) {
2683 - $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
2684 - if ($search_query) {
2685 - // Include search filter in the final fetch
2686 - $prompts_query = $wpdb->prepare(
2687 - "SELECT id, article_content, source_url, timestamp, role_restriction
2688 - FROM {$table_name}
2689 - WHERE source_url IN ($placeholders) AND article_content LIKE %s
2690 - ORDER BY timestamp DESC",
2691 - ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
2692 - );
2693 - } else {
2694 - $prompts_query = $wpdb->prepare(
2695 - "SELECT id, article_content, source_url, timestamp, role_restriction
2696 - FROM {$table_name}
2697 - WHERE source_url IN ($placeholders)
2698 - ORDER BY timestamp DESC",
2699 - $url_list
2700 - );
2701 - }
2702 - $prompts = $wpdb->get_results($prompts_query);
2703 - }
2704 -
2705 - // Group prompts by source_url for chunk display
2706 - $grouped_prompts = array();
2707 - foreach ($prompts as $prompt) {
2708 - $source_url = $prompt->source_url ?? '';
2709 -
2710 - // Parse chunk metadata using the proper chunker method (same as initial page load)
2711 - if (class_exists('MxChat_Chunker')) {
2712 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
2713 - $prompt->chunk_metadata = $chunk_meta['metadata'];
2714 - $prompt->display_content = $chunk_meta['text'];
2715 - } else {
2716 - $prompt->chunk_metadata = array();
2717 - $prompt->display_content = $prompt->article_content;
2718 - }
2719 -
2720 - if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
2721 - if (!isset($grouped_prompts[$source_url])) {
2722 - $grouped_prompts[$source_url] = array();
2723 - }
2724 - $grouped_prompts[$source_url][] = $prompt;
2725 - } else {
2726 - // Ungrouped entries
2727 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2728 - }
2729 - }
2730 -
2731 - // Sort groups by the original URL order (newest first)
2732 - uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
2733 - $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
2734 - $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
2735 - return $order_a - $order_b;
2736 - });
2737 -
2738 - // Sort each group internally by chunk_index
2739 - foreach ($grouped_prompts as $source_url => &$group) {
2740 - usort($group, function($a, $b) {
2741 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2742 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2743 - return $index_a - $index_b;
2744 - });
2745 - }
2746 - unset($group);
2747 -
2748 - // Build HTML for the table rows
2749 - ob_start();
2750 - $display_index = 0;
2751 - $current_page = $page;
2752 - $data_source = 'wordpress';
2753 - $current_bot_id = $bot_id;
2754 - $preview_length = 150;
2755 -
2756 - if (empty($grouped_prompts)) {
2757 - echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2758 - esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
2759 - echo '</td></tr>';
2760 - } else {
2761 - foreach ($grouped_prompts as $source_url => $group) {
2762 - $chunk_count = count($group);
2763 - $first_prompt = $group[0];
2764 - $display_index++;
2765 -
2766 - if ($chunk_count > 1) {
2767 - // Multiple chunks - show grouped row with expand button
2768 - $group_id = 'group-' . md5($source_url);
2769 - ?>
2770 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2771 - class="mxchat-chunk-group-header"
2772 - data-source="<?php echo esc_attr($data_source); ?>"
2773 - data-group-id="<?php echo esc_attr($group_id); ?>"
2774 - style="border-bottom: 1px solid var(--mxch-card-border);">
2775 - <td style="padding: 12px 16px; text-align: center;">
2776 - <input type="checkbox"
2777 - class="mxchat-entry-checkbox"
2778 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2779 - data-source="<?php echo esc_attr($data_source); ?>"
2780 - data-source-url="<?php echo esc_attr($source_url); ?>"
2781 - data-is-group="true"
2782 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2783 - </td>
2784 - <td style="padding: 12px 16px; font-size: 13px;">
2785 - <?php echo esc_html($first_prompt->id); ?>
2786 - </td>
2787 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2788 - <div class="mxchat-chunk-group-info">
2789 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2790 - <span class="dashicons dashicons-arrow-right-alt2"></span>
2791 - </button>
2792 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2793 - <span class="mxchat-chunk-preview">
2794 - <?php
2795 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
2796 - $content_preview = mb_substr($parent_content, 0, 100);
2797 - echo esc_html($content_preview . '...');
2798 - ?>
2799 - </span>
2800 - </div>
2801 - </td>
2802 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2803 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2804 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2805 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2806 - <?php esc_html_e('View Source', 'mxchat'); ?>
2807 - </a>
2808 - <?php else : ?>
2809 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2810 - <?php endif; ?>
2811 - </td>
2812 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2813 - <?php if ($data_source !== 'pinecone') : ?>
2814 - <button type="button"
2815 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2816 - data-source-url="<?php echo esc_attr($source_url); ?>"
2817 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2818 - data-data-source="<?php echo esc_attr($data_source); ?>"
2819 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2820 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2821 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2822 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2823 - </button>
2824 - <?php endif; ?>
2825 - <button type="button"
2826 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2827 - data-source-url="<?php echo esc_attr($source_url); ?>"
2828 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2829 - data-data-source="<?php echo esc_attr($data_source); ?>"
2830 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2831 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2832 - style="color: var(--mxch-error);"
2833 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2834 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2835 - </button>
2836 - </td>
2837 - </tr>
2838 - <?php
2839 - // Render hidden chunk rows
2840 - foreach ($group as $chunk_index => $chunk) {
2841 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2842 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2843 - $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
2844 - $content_preview = mb_strlen($content) > $preview_length
2845 - ? mb_substr($content, 0, $preview_length) . '...'
2846 - : $content;
2847 - ?>
2848 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2849 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2850 - data-source="<?php echo esc_attr($data_source); ?>"
2851 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2852 - <td style="padding: 12px 16px; text-align: center;">
2853 - <!-- Checkbox column placeholder for chunks (managed by group) -->
2854 - </td>
2855 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2856 - <!-- Hidden ID column for chunks -->
2857 - </td>
2858 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2859 - <div class="mxchat-accordion-wrapper">
2860 - <div class="mxchat-content-preview">
2861 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2862 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2863 - </span>
2864 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2865 - <?php if (mb_strlen($content) > $preview_length) : ?>
2866 - <button class="mxchat-expand-toggle" type="button">
2867 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2868 - </button>
2869 - <?php endif; ?>
2870 - </div>
2871 - <div class="mxchat-content-full" style="display: none;">
2872 - <div class="content-view">
2873 - <?php
2874 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2875 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2876 - echo wp_kses_post(wpautop($content));
2877 - echo '</div>';
2878 - } else {
2879 - echo wp_kses_post(wpautop($content));
2880 - }
2881 - ?>
2882 - </div>
2883 - </div>
2884 - </div>
2885 - </td>
2886 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2887 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2888 - </td>
2889 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2890 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2891 - </td>
2892 - </tr>
2893 - <?php
2894 - }
2895 - } else {
2896 - // Single entry - display normally with accordion
2897 - $prompt = $first_prompt;
2898 - $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
2899 - $content_preview = mb_strlen($content) > $preview_length
2900 - ? mb_substr($content, 0, $preview_length) . '...'
2901 - : $content;
2902 - ?>
2903 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2904 - data-source="<?php echo esc_attr($data_source); ?>"
2905 - style="border-bottom: 1px solid var(--mxch-card-border);">
2906 - <td style="padding: 12px 16px; text-align: center;">
2907 - <input type="checkbox"
2908 - class="mxchat-entry-checkbox"
2909 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2910 - data-source="<?php echo esc_attr($data_source); ?>"
2911 - data-source-url="<?php echo esc_attr($source_url); ?>"
2912 - data-is-group="false">
2913 - </td>
2914 - <td style="padding: 12px 16px; font-size: 13px;">
2915 - <?php echo esc_html($prompt->id); ?>
2916 - </td>
2917 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2918 - <div class="mxchat-accordion-wrapper">
2919 - <div class="mxchat-content-preview">
2920 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2921 - <?php if (mb_strlen($content) > $preview_length) : ?>
2922 - <button class="mxchat-expand-toggle" type="button">
2923 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2924 - </button>
2925 - <?php endif; ?>
2926 - </div>
2927 - <div class="mxchat-content-full" style="display: none;">
2928 - <div class="content-view">
2929 - <?php
2930 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2931 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2932 - echo wp_kses_post(wpautop($content));
2933 - echo '</div>';
2934 - } else {
2935 - echo wp_kses_post(wpautop($content));
2936 - }
2937 - ?>
2938 - </div>
2939 - </div>
2940 - </div>
2941 - </td>
2942 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2943 - <?php
2944 - $actual_source = $source_url;
2945 - if (strpos($source_url, '_ungrouped_') === 0) {
2946 - $actual_source = $prompt->source_url ?? '';
2947 - }
2948 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2949 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2950 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2951 - <?php esc_html_e('View', 'mxchat'); ?>
2952 - </a>
2953 - <?php else : ?>
2954 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2955 - <?php endif; ?>
2956 - </td>
2957 - <td style="padding: 12px 16px; white-space: nowrap;">
2958 - <button type="button"
2959 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2960 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
2961 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2962 - data-data-source="<?php echo esc_attr($data_source); ?>"
2963 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2964 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2965 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2966 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2967 - </button>
2968 - <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);">
2969 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2970 - </button>
2971 - </td>
2972 - </tr>
2973 - <?php
2974 - }
2975 - }
2976 - }
2977 - $html = ob_get_clean();
2978 -
2979 - // Generate pagination HTML (include search/filter data for subsequent pages)
2980 - $pagination_html = '';
2981 - if ($total_pages > 1) {
2982 - $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) . '">';
2983 -
2984 - // Previous button
2985 - if ($page > 1) {
2986 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2987 - }
2988 -
2989 - // Page numbers
2990 - $start_page = max(1, $page - 2);
2991 - $end_page = min($total_pages, $page + 2);
2992 -
2993 - if ($start_page > 1) {
2994 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2995 - if ($start_page > 2) {
2996 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2997 - }
2998 - }
2999 -
3000 - for ($i = $start_page; $i <= $end_page; $i++) {
3001 - if ($i == $page) {
3002 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3003 - } else {
3004 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3005 - }
3006 - }
3007 -
3008 - if ($end_page < $total_pages) {
3009 - if ($end_page < $total_pages - 1) {
3010 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3011 - }
3012 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3013 - }
3014 -
3015 - // Next button
3016 - if ($page < $total_pages) {
3017 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3018 - }
3019 -
3020 - $pagination_html .= '</div>';
3021 - }
3022 -
3023 - wp_send_json_success(array(
3024 - 'html' => $html,
3025 - 'pagination_html' => $pagination_html,
3026 - 'total_count' => $total_records,
3027 - 'total_pages' => $total_pages,
3028 - 'page' => $page,
3029 - 'per_page' => $per_page,
3030 - 'data_source' => 'wordpress'
3031 - ));
3032 -}
3033 -
3034 -/**
3035 - * AJAX handler to detect available sitemaps on the site
3036 - * Optimized for speed - only checks primary sitemap indexes first
3037 - */
3038 -public function ajax_mxchat_detect_sitemaps() {
3039 - check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
3040 -
3041 - if (!current_user_can('manage_options')) {
3042 - wp_send_json_error(array('message' => 'Unauthorized'));
3043 - return;
3044 - }
3045 -
3046 - $site_url = get_site_url();
3047 - $sitemaps = array();
3048 - $found_index = false;
3049 -
3050 - // Only check the main sitemap index files first (much faster)
3051 - // These are the primary entry points that contain sub-sitemaps
3052 - $primary_indexes = array(
3053 - 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
3054 - 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
3055 - 'sitemap.xml' => 'Standard', // Generic/AIOSEO
3056 - );
3057 -
3058 - foreach ($primary_indexes as $path => $source) {
3059 - $url = trailingslashit($site_url) . $path;
3060 -
3061 - $response = wp_remote_head($url, array(
3062 - 'timeout' => 10,
3063 - 'sslverify' => false,
3064 - 'redirection' => 1,
3065 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3066 - ));
3067 -
3068 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3069 - // Found a sitemap index - parse it to get sub-sitemaps
3070 - $sub_sitemaps = $this->parse_sitemap_index($url);
3071 - if (!empty($sub_sitemaps)) {
3072 - $sitemaps[] = array(
3073 - 'url' => $url,
3074 - 'type' => 'index',
3075 - 'source' => $source,
3076 - 'sub_sitemaps' => $sub_sitemaps
3077 - );
3078 - $found_index = true;
3079 - // Found a valid index, no need to check others
3080 - break;
3081 - }
3082 - }
3083 - }
3084 -
3085 - // If no sitemap index found, check for standalone sitemaps
3086 - if (!$found_index) {
3087 - $standalone_sitemaps = array(
3088 - 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3089 - 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3090 - );
3091 -
3092 - foreach ($standalone_sitemaps as $path => $info) {
3093 - $url = trailingslashit($site_url) . $path;
3094 -
3095 - $response = wp_remote_head($url, array(
3096 - 'timeout' => 2,
3097 - 'sslverify' => false
3098 - ));
3099 -
3100 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3101 - $sitemaps[] = array(
3102 - 'url' => $url,
3103 - 'type' => $info['type'],
3104 - 'source' => $info['source'],
3105 - 'url_count' => 0 // Skip URL count for speed
3106 - );
3107 - }
3108 - }
3109 - }
3110 -
3111 - wp_send_json_success(array(
3112 - 'sitemaps' => $sitemaps,
3113 - 'site_url' => $site_url
3114 - ));
3115 -}
3116 -
3117 -/**
3118 - * Parse a sitemap index to get sub-sitemaps
3119 - * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
3120 - */
3121 -private function parse_sitemap_index($url) {
3122 - $sub_sitemaps = array();
3123 -
3124 - $response = wp_remote_get($url, array(
3125 - 'timeout' => 30,
3126 - 'sslverify' => false,
3127 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3128 - 'headers' => array(
3129 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3130 - 'Accept-Language' => 'en-US,en;q=0.9',
3131 - ),
3132 - ));
3133 -
3134 - if (is_wp_error($response)) {
3135 - return $sub_sitemaps;
3136 - }
3137 -
3138 - $body = wp_remote_retrieve_body($response);
3139 - if (empty($body)) {
3140 - return $sub_sitemaps;
3141 - }
3142 -
3143 - // Suppress XML errors
3144 - libxml_use_internal_errors(true);
3145 - $xml = simplexml_load_string($body);
3146 - libxml_clear_errors();
3147 -
3148 - if ($xml === false) {
3149 - return $sub_sitemaps;
3150 - }
3151 -
3152 - // Check if it's a sitemap index (contains <sitemap> elements)
3153 - if (isset($xml->sitemap)) {
3154 - foreach ($xml->sitemap as $sitemap) {
3155 - $loc = (string) $sitemap->loc;
3156 - if (!empty($loc)) {
3157 - // Try to determine the type from the URL
3158 - $type = 'content';
3159 - if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
3160 - $type = 'taxonomy';
3161 - } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
3162 - $type = 'author';
3163 - }
3164 -
3165 - // Skip URL count - too slow to fetch for each sitemap
3166 - $sub_sitemaps[] = array(
3167 - 'url' => $loc,
3168 - 'type' => $type,
3169 - 'url_count' => 0, // Don't fetch - takes too long
3170 - 'name' => basename(parse_url($loc, PHP_URL_PATH))
3171 - );
3172 - }
3173 - }
3174 - }
3175 -
3176 - return $sub_sitemaps;
3177 -}
3178 -
3179 -/**
3180 - * Get URL count from a sitemap
3181 - */
3182 -private function get_sitemap_url_count($url) {
3183 - $response = wp_remote_get($url, array(
3184 - 'timeout' => 30,
3185 - 'sslverify' => false,
3186 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3187 - 'headers' => array(
3188 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3189 - 'Accept-Language' => 'en-US,en;q=0.9',
3190 - ),
3191 - ));
3192 -
3193 - if (is_wp_error($response)) {
3194 - return 0;
3195 - }
3196 -
3197 - $body = wp_remote_retrieve_body($response);
3198 - if (empty($body)) {
3199 - return 0;
3200 - }
3201 -
3202 - // Count <url> or <loc> elements
3203 - $count = preg_match_all('/<url>/i', $body, $matches);
3204 - return $count ?: 0;
3205 -}
3206 -
3207 -/**
3208 - * Get sitemaps declared in robots.txt
3209 - */
3210 -private function get_sitemaps_from_robots($site_url) {
3211 - $sitemaps = array();
3212 - $robots_url = trailingslashit($site_url) . 'robots.txt';
3213 -
3214 - $response = wp_remote_get($robots_url, array(
3215 - 'timeout' => 15,
3216 - 'sslverify' => false,
3217 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3218 - ));
3219 -
3220 - if (is_wp_error($response)) {
3221 - return $sitemaps;
3222 - }
3223 -
3224 - $body = wp_remote_retrieve_body($response);
3225 - if (empty($body)) {
3226 - return $sitemaps;
3227 - }
3228 -
3229 - // Find Sitemap: declarations
3230 - if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
3231 - foreach ($matches[1] as $sitemap_url) {
3232 - $sitemap_url = trim($sitemap_url);
3233 - if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
3234 - $sitemaps[] = $sitemap_url;
3235 - }
3236 - }
3237 - }
3238 -
3239 - return $sitemaps;
3240 -}
3241 -
3242 -public function mxchat_stop_processing() {
3243 - // Verify permissions
3244 - if (!current_user_can('manage_options')) {
3245 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
3246 - }
3247 -
3248 - // Verify nonce
3249 - check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
3250 -
3251 - global $wpdb;
3252 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
3253 -
3254 - // Get active queue IDs
3255 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
3256 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
3257 -
3258 - // Delete all pending items from active queues
3259 - if ($sitemap_queue_id) {
3260 - $wpdb->delete(
3261 - $table_name,
3262 - array(
3263 - 'queue_id' => $sitemap_queue_id,
3264 - 'status' => 'pending'
3265 - ),
3266 - array('%s', '%s')
3267 - );
3268 -
3269 - delete_transient('mxchat_active_queue_sitemap');
3270 - delete_transient('mxchat_last_sitemap_url');
3271 - }
3272 -
3273 - if ($pdf_queue_id) {
3274 - // Get PDF path before deleting
3275 - $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
3276 -
3277 - $wpdb->delete(
3278 - $table_name,
3279 - array(
3280 - 'queue_id' => $pdf_queue_id,
3281 - 'status' => 'pending'
3282 - ),
3283 - array('%s', '%s')
3284 - );
3285 -
3286 - // Delete PDF file
3287 - if ($pdf_path && file_exists($pdf_path)) {
3288 - wp_delete_file($pdf_path);
3289 - }
3290 -
3291 - delete_transient('mxchat_active_queue_pdf');
3292 - delete_transient('mxchat_last_pdf_url');
3293 - }
3294 -
3295 - // Redirect back with a success message
3296 - set_transient('mxchat_admin_notice_success',
3297 - esc_html__('Processing has been stopped successfully.', 'mxchat'),
3298 - 30
3299 - );
3300 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
3301 - exit;
3302 -}
3303 -
3304 -/**
3305 - * Get content list for processing
3306 - */
3307 -public function ajax_mxchat_get_content_list() {
3308 - // Verify the nonce
3309 - check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
3310 -
3311 - if (!current_user_can('manage_options')) {
3312 - wp_send_json_error(__('Unauthorized access', 'mxchat'));
3313 - }
3314 -
3315 - $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
3316 - $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
3317 - $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
3318 - $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
3319 - $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
3320 - $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
3321 -
3322 - // Build query args
3323 - $args = array(
3324 - 'posts_per_page' => $per_page,
3325 - 'paged' => $page,
3326 - 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
3327 - 'orderby' => 'date',
3328 - 'order' => 'DESC',
3329 - );
3330 -
3331 - // Handle post types - IMPROVED VERSION
3332 - if ($post_type !== 'all') {
3333 - $args['post_type'] = $post_type;
3334 - } else {
3335 - // Get all available post types that might contain content
3336 - $all_post_types = array();
3337 -
3338 - // First get all public post types
3339 - $public_types = get_post_types(array('public' => true), 'names');
3340 - $all_post_types = array_merge($all_post_types, $public_types);
3341 -
3342 - // Add common forum/community post types
3343 - $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
3344 - foreach ($forum_types as $forum_type) {
3345 - if (post_type_exists($forum_type)) {
3346 - $all_post_types[] = $forum_type;
3347 - }
3348 - }
3349 -
3350 - // Add other commonly used post types
3351 - $common_types = array('product', 'job_listing', 'event', 'portfolio');
3352 - foreach ($common_types as $common_type) {
3353 - if (post_type_exists($common_type)) {
3354 - $all_post_types[] = $common_type;
3355 - }
3356 - }
3357 -
3358 - // Remove duplicates and ensure we have at least some post types
3359 - $all_post_types = array_unique($all_post_types);
3360 -
3361 - if (empty($all_post_types)) {
3362 - // Fallback to basic post types
3363 - $all_post_types = array('post', 'page');
3364 - }
3365 -
3366 - $args['post_type'] = $all_post_types;
3367 -
3368 - // Debug logging to see what post types are being queried
3369 - //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
3370 - }
3371 -
3372 - if (!empty($search)) {
3373 - $args['s'] = $search;
3374 - }
3375 -
3376 - // Get processed data from storage
3377 - $processed_data = array();
3378 -
3379 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3380 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3381 -
3382 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3383 - // Get fresh data from Pinecone - no caching
3384 - $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
3385 - } else {
3386 - // WordPress DB checking with better URL matching for all post types
3387 - global $wpdb;
3388 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3389 - $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
3390 -
3391 - // Group items by source_url to count chunks
3392 - $url_chunk_counts = array();
3393 - $url_latest_timestamp = array();
3394 - $url_first_id = array();
3395 -
3396 - if (!empty($processed_items)) {
3397 - foreach ($processed_items as $item) {
3398 - $url = $item->source_url;
3399 - if (empty($url)) continue;
3400 -
3401 - // Count chunks per URL
3402 - if (!isset($url_chunk_counts[$url])) {
3403 - $url_chunk_counts[$url] = 0;
3404 - $url_latest_timestamp[$url] = $item->timestamp;
3405 - $url_first_id[$url] = $item->id;
3406 - }
3407 - $url_chunk_counts[$url]++;
3408 -
3409 - // Track latest timestamp
3410 - if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
3411 - $url_latest_timestamp[$url] = $item->timestamp;
3412 - }
3413 - }
3414 -
3415 - // Now build processed_data with chunk counts
3416 - foreach ($url_chunk_counts as $url => $chunk_count) {
3417 - $post_id = $this->mxchat_url_to_post_id_improved($url);
3418 -
3419 - if ($post_id) {
3420 - $processed_data[$post_id] = array(
3421 - 'db_id' => $url_first_id[$url],
3422 - 'timestamp' => $url_latest_timestamp[$url],
3423 - 'url' => $url,
3424 - 'source' => 'wordpress',
3425 - 'chunk_count' => $chunk_count
3426 - );
3427 - }
3428 - }
3429 - }
3430 - }
3431 -
3432 - // Get processed IDs as a simple array for in_array checks
3433 - $processed_ids = array_keys($processed_data);
3434 -
3435 - // Handle processed/unprocessed filter
3436 - if ($processed_filter === 'processed' && !empty($processed_ids)) {
3437 - $args['post__in'] = $processed_ids;
3438 - } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
3439 - $args['post__not_in'] = $processed_ids;
3440 - }
3441 -
3442 - // Run the query
3443 - $query = new WP_Query($args);
3444 - $content_items = array();
3445 -
3446 - if ($query->have_posts()) {
3447 - while ($query->have_posts()) {
3448 - $query->the_post();
3449 - $id = get_the_ID();
3450 - $post_date = get_the_date();
3451 - $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
3452 - $word_count = str_word_count(strip_tags(get_the_content()));
3453 -
3454 - $is_processed = in_array($id, $processed_ids);
3455 - $processed_date = '';
3456 - $db_record_id = 0;
3457 - $data_source = 'none';
3458 -
3459 - if ($is_processed && isset($processed_data[$id])) {
3460 - $item_data = $processed_data[$id];
3461 - $data_source = $item_data['source'];
3462 -
3463 - if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
3464 - // WordPress DB format
3465 - $timestamp = strtotime($item_data['timestamp']);
3466 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3467 - $db_record_id = $item_data['db_id'];
3468 - } elseif ($data_source === 'pinecone') {
3469 - // Pinecone format
3470 - $processed_date = $item_data['processed_date'];
3471 - $db_record_id = $item_data['db_id'];
3472 - }
3473 - }
3474 -
3475 - // Get chunk count for this item
3476 - $chunk_count = 0;
3477 - if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
3478 - $chunk_count = intval($processed_data[$id]['chunk_count']);
3479 - }
3480 -
3481 - $content_items[] = array(
3482 - 'id' => $id,
3483 - 'title' => get_the_title(),
3484 - 'permalink' => get_permalink(),
3485 - 'date' => $post_date,
3486 - 'type' => get_post_type(),
3487 - 'status' => get_post_status(),
3488 - 'excerpt' => $excerpt,
3489 - 'word_count' => $word_count,
3490 - 'already_processed' => $is_processed,
3491 - 'processed_date' => $processed_date,
3492 - 'db_record_id' => $db_record_id,
3493 - 'data_source' => $data_source,
3494 - 'chunk_count' => $chunk_count
3495 - );
3496 - }
3497 - wp_reset_postdata();
3498 - }
3499 -
3500 - $response = array(
3501 - 'items' => $content_items,
3502 - 'total' => $query->found_posts,
3503 - 'total_pages' => $query->max_num_pages,
3504 - 'current_page' => $page,
3505 - 'processed_count' => count($processed_ids)
3506 - );
3507 -
3508 - wp_send_json_success($response);
3509 - exit;
3510 -}
3511 -
3512 -
3513 -/**
3514 - * This function handles various WooCommerce URL formats and permalink structures
3515 - */
3516 -private function mxchat_url_to_post_id_improved($url) {
3517 - // First try the standard WordPress function
3518 - $post_id = url_to_postid($url);
3519 -
3520 - if ($post_id > 0) {
3521 - return $post_id;
3522 - }
3523 -
3524 - // If that fails, try more aggressive URL matching
3525 - // Remove trailing slashes and query parameters for better matching
3526 - $clean_url = rtrim($url, '/');
3527 - $clean_url = strtok($clean_url, '?'); // Remove query parameters
3528 -
3529 - // Try again with cleaned URL
3530 - $post_id = url_to_postid($clean_url);
3531 - if ($post_id > 0) {
3532 - return $post_id;
3533 - }
3534 -
3535 - // For bbPress forum topics, try extracting slug from URL
3536 - if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
3537 - // Handle bbPress URLs: /forums/topic/topic-name/
3538 - if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
3539 - $topic_slug = $matches[1];
3540 -
3541 - // Look up topic by slug
3542 - $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
3543 - if ($topic) {
3544 - return $topic->ID;
3545 - }
3546 -
3547 - // Alternative method: query by post_name
3548 - global $wpdb;
3549 - $post_id = $wpdb->get_var($wpdb->prepare(
3550 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3551 - $topic_slug
3552 - ));
3553 -
3554 - if ($post_id) {
3555 - return intval($post_id);
3556 - }
3557 - }
3558 -
3559 - // Handle simpler topic URLs: /topic/topic-name/
3560 - if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
3561 - $topic_slug = $matches[1];
3562 -
3563 - global $wpdb;
3564 - $post_id = $wpdb->get_var($wpdb->prepare(
3565 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3566 - $topic_slug
3567 - ));
3568 -
3569 - if ($post_id) {
3570 - return intval($post_id);
3571 - }
3572 - }
3573 - }
3574 -
3575 - // For WooCommerce products
3576 - if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
3577 - // Extract product slug from various URL formats
3578 - $product_slug = '';
3579 -
3580 - // Handle pretty permalinks: /product/product-name/
3581 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
3582 - $product_slug = $matches[1];
3583 - }
3584 - // Handle query parameters: ?product=product-name
3585 - elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
3586 - $product_slug = $matches[1];
3587 - }
3588 -
3589 - if (!empty($product_slug)) {
3590 - // Look up product by slug
3591 - $product = get_page_by_path($product_slug, OBJECT, 'product');
3592 - if ($product) {
3593 - return $product->ID;
3594 - }
3595 -
3596 - // Alternative method: query by post_name
3597 - global $wpdb;
3598 - $post_id = $wpdb->get_var($wpdb->prepare(
3599 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
3600 - $product_slug
3601 - ));
3602 -
3603 - if ($post_id) {
3604 - return intval($post_id);
3605 - }
3606 - }
3607 - }
3608 -
3609 - // Generic approach: try to extract slug and match against all post types
3610 - $parsed_url = wp_parse_url($clean_url);
3611 - $path = $parsed_url['path'] ?? '';
3612 -
3613 - if (!empty($path)) {
3614 - // Get the last part of the path as potential slug
3615 - $path_parts = array_filter(explode('/', trim($path, '/')));
3616 - $potential_slug = end($path_parts);
3617 -
3618 - if (!empty($potential_slug)) {
3619 - global $wpdb;
3620 -
3621 - // Try to find any post with this slug
3622 - $post_id = $wpdb->get_var($wpdb->prepare(
3623 - "SELECT ID FROM {$wpdb->posts}
3624 - WHERE post_name = %s
3625 - AND post_status IN ('publish', 'closed', 'private')
3626 - AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
3627 - ORDER BY CASE
3628 - WHEN post_type = 'post' THEN 1
3629 - WHEN post_type = 'page' THEN 2
3630 - WHEN post_type = 'topic' THEN 3
3631 - WHEN post_type = 'product' THEN 4
3632 - ELSE 5
3633 - END
3634 - LIMIT 1",
3635 - $potential_slug
3636 - ));
3637 -
3638 - if ($post_id) {
3639 - return intval($post_id);
3640 - }
3641 - }
3642 - }
3643 -
3644 - // ADDITIONAL: Try direct database lookup by URL variations
3645 - global $wpdb;
3646 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3647 -
3648 - // Try variations of the URL (with/without trailing slash, http/https)
3649 - $url_variations = array(
3650 - $url,
3651 - rtrim($url, '/'),
3652 - $url . '/',
3653 - str_replace('http://', 'https://', $url),
3654 - str_replace('https://', 'http://', $url),
3655 - str_replace('http://', 'https://', rtrim($url, '/')),
3656 - str_replace('https://', 'http://', rtrim($url, '/'))
3657 - );
3658 -
3659 - // Remove duplicates
3660 - $url_variations = array_unique($url_variations);
3661 -
3662 - foreach ($url_variations as $variation) {
3663 - $existing_record = $wpdb->get_row($wpdb->prepare(
3664 - "SELECT id, source_url FROM $table_name WHERE source_url = %s",
3665 - $variation
3666 - ));
3667 -
3668 - if ($existing_record) {
3669 - // Try to get post ID from this stored URL
3670 - $stored_post_id = url_to_postid($existing_record->source_url);
3671 - if ($stored_post_id > 0) {
3672 - return $stored_post_id;
3673 - }
3674 - }
3675 - }
3676 -
3677 - return 0; // No match found
3678 -}
3679 -/**
3680 - * Process selected content via AJAX
3681 - */
3682 -public function ajax_mxchat_process_selected_content() {
3683 - // Basic request validation
3684 - if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
3685 - wp_send_json_error('Invalid nonce');
3686 - exit;
3687 - }
3688 -
3689 - if (!current_user_can('manage_options')) {
3690 - wp_send_json_error('Unauthorized access');
3691 - exit;
3692 - }
3693 -
3694 - // Get post IDs - safely parse the array
3695 - $post_ids = array();
3696 - if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
3697 - foreach ($_POST['post_ids'] as $id) {
3698 - $post_ids[] = absint($id);
3699 - }
3700 - }
3701 -
3702 - if (empty($post_ids)) {
3703 - wp_send_json_error('No content selected');
3704 - exit;
3705 - }
3706 -
3707 - // Get bot_id from request
3708 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3709 -
3710 - // ACF→PDF extraction is opt-in per import batch. Persist the last-used value so users
3711 - // don't re-check on every batch; the default is OFF for installs that haven't set it.
3712 - $extract_acf_pdfs = !empty($_POST['extract_acf_pdfs']) && $_POST['extract_acf_pdfs'] !== 'false';
3713 - $mxchat_options = get_option('mxchat_options', array());
3714 - if (!is_array($mxchat_options)) {
3715 - $mxchat_options = array();
3716 - }
3717 - $prior_default = !empty($mxchat_options['acf_pdf_extract_default']);
3718 - if ($prior_default !== $extract_acf_pdfs) {
3719 - $mxchat_options['acf_pdf_extract_default'] = $extract_acf_pdfs ? 1 : 0;
3720 - update_option('mxchat_options', $mxchat_options);
3721 - }
3722 -
3723 - // Process only ONE post at a time to avoid request size issues
3724 - $post_id = reset($post_ids);
3725 - $post = get_post($post_id);
3726 -
3727 - if (!$post) {
3728 - wp_send_json_error('Post not found');
3729 - exit;
3730 - }
3731 -
3732 - // Allow developers to modify post data before processing into knowledge base
3733 - $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
3734 -
3735 - // Get content including title, short description (for WooCommerce), and main content
3736 - $content = $post->post_title . "\n\n";
3737 -
3738 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
3739 - if (!empty($post->post_excerpt)) {
3740 - // Remove shortcode tags but preserve content inside them
3741 - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
3742 - $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
3743 - }
3744 -
3745 - // Add main content - remove shortcode tags but preserve content inside them
3746 - $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
3747 - $content .= wp_strip_all_tags($clean_content);
3748 -
3749 - // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
3750 - if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
3751 - $product = wc_get_product($post_id);
3752 -
3753 - if ($product) {
3754 - // Get pricing information
3755 - $regular_price = $product->get_regular_price();
3756 - $sale_price = $product->get_sale_price();
3757 - $price = $product->get_price();
3758 - $sku = $product->get_sku();
3759 -
3760 - // Get currency symbol
3761 - $currency_symbol = get_woocommerce_currency_symbol();
3762 -
3763 - // Add pricing information
3764 - $content .= "\n";
3765 - if (!empty($regular_price)) {
3766 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
3767 - } elseif (!empty($price)) {
3768 - $content .= "Price: " . $currency_symbol . $price . "\n";
3769 - }
3770 -
3771 - if (!empty($sale_price) && $sale_price !== $regular_price) {
3772 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
3773 - }
3774 -
3775 - // Handle variable products - show price range
3776 - if ($product->is_type('variable')) {
3777 - $min_price = $product->get_variation_price('min');
3778 - $max_price = $product->get_variation_price('max');
3779 - if ($min_price !== $max_price) {
3780 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
3781 - }
3782 - }
3783 -
3784 - if (!empty($sku)) {
3785 - $content .= "SKU: " . $sku . "\n";
3786 - }
3787 -
3788 - // Get product categories
3789 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
3790 - if (!empty($categories) && !is_wp_error($categories)) {
3791 - $content .= "Categories: " . implode(', ', $categories) . "\n";
3792 - }
3793 - }
3794 -
3795 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
3796 - $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
3797 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
3798 - foreach ($custom_tabs as $tab) {
3799 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3800 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
3801 -
3802 - if (!empty($tab_title) && !empty($tab_content)) {
3803 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3804 - }
3805 - }
3806 - }
3807 -
3808 - // Also check for reusable/saved tabs applied to this product
3809 - $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
3810 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
3811 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
3812 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
3813 - foreach ($applied_saved_tabs as $saved_tab_id) {
3814 - if (isset($saved_tabs[$saved_tab_id])) {
3815 - $tab = $saved_tabs[$saved_tab_id];
3816 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3817 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
3818 -
3819 - if (!empty($tab_title) && !empty($tab_content)) {
3820 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3821 - }
3822 - }
3823 - }
3824 - }
3825 - }
3826 - }
3827 -
3828 - // ADD ACF FIELDS SUPPORT
3829 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3830 - $pdf_extracted_count = 0;
3831 - if (!empty($acf_fields)) {
3832 - $acf_content_parts = array();
3833 - $pdf_attachment_ids = array();
3834 -
3835 - foreach ($acf_fields as $field_name => $field_value) {
3836 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3837 -
3838 - if (!empty($formatted_value)) {
3839 - $field_label = ucwords(str_replace('_', ' ', $field_name));
3840 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
3841 - }
3842 -
3843 - // Walk this field's value tree for any PDF attachment references and queue them for extraction.
3844 - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
3845 - // still lands in the KB but the heavier PDF parsing is skipped.
3846 - if ($extract_acf_pdfs) {
3847 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
3848 - }
3849 - }
3850 -
3851 - if (!empty($acf_content_parts)) {
3852 - $content .= "\n\n" . implode("\n", $acf_content_parts);
3853 - }
3854 -
3855 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
3856 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
3857 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
3858 - $pdf_sections = array();
3859 - foreach ($pdf_attachment_ids as $att_id) {
3860 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
3861 - if (!empty($pdf_text)) {
3862 - $pdf_title = get_the_title($att_id);
3863 - $pdf_url = wp_get_attachment_url($att_id);
3864 - $header = 'PDF Attachment';
3865 - if (!empty($pdf_title)) {
3866 - $header .= ': ' . $pdf_title;
3867 - }
3868 - if (!empty($pdf_url)) {
3869 - $header .= ' (' . $pdf_url . ')';
3870 - }
3871 - $pdf_sections[] = $header . "\n" . $pdf_text;
3872 - $pdf_extracted_count++;
3873 - }
3874 - }
3875 - if (!empty($pdf_sections)) {
3876 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
3877 - }
3878 - }
3879 - }
3880 -
3881 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3882 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
3883 - if (!empty($custom_meta)) {
3884 - $meta_content_parts = array();
3885 -
3886 - foreach ($custom_meta as $meta_key => $meta_value) {
3887 - // Convert meta key to readable label
3888 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
3889 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
3890 - }
3891 -
3892 - if (!empty($meta_content_parts)) {
3893 - $content .= "\n\n" . implode("\n", $meta_content_parts);
3894 - }
3895 - }
3896 -
3897 - // Debug logging for WordPress Import content
3898 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
3899 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
3900 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
3901 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
3902 -
3903 - // Note: Removed 10,000 char limit - chunking now handles large content properly
3904 -
3905 - // Get bot-specific API key
3906 - $bot_options = $this->get_bot_options($bot_id);
3907 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3908 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3909 -
3910 - if (strpos($selected_model, 'voyage') === 0) {
3911 - $api_key = $options['voyage_api_key'] ?? '';
3912 - $provider_name = 'Voyage AI';
3913 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3914 - $api_key = $options['gemini_api_key'] ?? '';
3915 - $provider_name = 'Google Gemini';
3916 - } else {
3917 - $api_key = $options['api_key'] ?? '';
3918 - $provider_name = 'OpenAI';
3919 - }
3920 -
3921 - if (empty($api_key)) {
3922 - MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing');
3923 - wp_send_json_error($provider_name . ' API key not configured');
3924 - exit;
3925 - }
3926 -
3927 - $source_url = get_permalink($post_id);
3928 - $vector_id = md5($source_url); // Vector ID for Pinecone
3929 -
3930 - // Check for existing content in bot-specific storage
3931 - $is_update = false;
3932 -
3933 - // Get bot-specific Pinecone configuration
3934 - $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
3935 - $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
3936 -
3937 - if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
3938 - // Check Pinecone for this bot
3939 - $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
3940 - if (isset($pinecone_data[$post_id])) {
3941 - $is_update = true;
3942 - }
3943 - } else {
3944 - // Check WordPress DB (same as before since it's shared)
3945 - global $wpdb;
3946 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3947 - $existing_record = $wpdb->get_row($wpdb->prepare(
3948 - "SELECT id FROM $table_name WHERE source_url = %s",
3949 - $source_url
3950 - ));
3951 -
3952 - if ($existing_record) {
3953 - $is_update = true;
3954 - }
3955 - }
3956 -
3957 - // UPDATED 2.5.6: Determine content type based on post_type
3958 - $post_type = $post->post_type;
3959 - $content_type = 'content'; // Default fallback
3960 -
3961 - // Map WordPress post types to content types
3962 - switch ($post_type) {
3963 - case 'post':
3964 - $content_type = 'post';
3965 - break;
3966 - case 'page':
3967 - $content_type = 'page';
3968 - break;
3969 - case 'product':
3970 - $content_type = 'product';
3971 - break;
3972 - default:
3973 - // For custom post types, use the post type name
3974 - $content_type = sanitize_key($post_type);
3975 - break;
3976 - }
3977 -
3978 - // Use the centralized utility function with bot_id and content_type
3979 - $result = MxChat_Utils::submit_content_to_db(
3980 - $content,
3981 - $source_url,
3982 - $api_key,
3983 - $vector_id,
3984 - $bot_id,
3985 - $content_type
3986 - );
3987 -
3988 - if (is_wp_error($result)) {
3989 - MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
3990 - wp_send_json_error('Storage failed: ' . $result->get_error_message());
3991 - exit;
3992 - }
3993 -
3994 - // Automatically apply role restriction based on tags
3995 - $this->apply_role_restriction_to_post($post_id, $source_url);
3996 -
3997 - $operation_type = $is_update ? 'update' : 'new';
3998 -
3999 - // Count ACF fields for debugging
4000 - $acf_field_count = count($acf_fields);
4001 -
4002 - // Success response with minimal data
4003 - wp_send_json_success(array(
4004 - 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
4005 - 'post_id' => $post_id,
4006 - 'title' => $post->post_title,
4007 - 'operation_type' => $operation_type,
4008 - 'vector_id' => $vector_id,
4009 - 'acf_fields_found' => $acf_field_count,
4010 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4011 - 'content_preview' => substr($content, 0, 100) . '...',
4012 - 'bot_id' => $bot_id
4013 - ));
4014 - exit;
4015 -}
4016 -
4017 -private function apply_role_restriction_to_post($post_id, $source_url) {
4018 - // Get tag-role mappings
4019 - $mappings = get_option('mxchat_tag_role_mappings', array());
4020 -
4021 - if (empty($mappings)) {
4022 - return; // No mappings, leave as public
4023 - }
4024 -
4025 - // Get all tags for the post
4026 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
4027 -
4028 - if (empty($post_tags)) {
4029 - return; // No tags, leave as public
4030 - }
4031 -
4032 - // Determine the highest role restriction based on tags
4033 - $highest_role = 'public';
4034 - $role_hierarchy = array(
4035 - 'public' => 0,
4036 - 'logged_in' => 1,
4037 - 'subscriber' => 2,
4038 - 'contributor' => 3,
4039 - 'author' => 4,
4040 - 'editor' => 5,
4041 - 'administrator' => 6
4042 - );
4043 -
4044 - foreach ($post_tags as $tag_slug) {
4045 - if (isset($mappings[$tag_slug])) {
4046 - $role = $mappings[$tag_slug];
4047 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
4048 - $highest_role = $role;
4049 - }
4050 - }
4051 - }
4052 -
4053 - // If no restricted tags found, return (leave as public)
4054 - if ($highest_role === 'public') {
4055 - return;
4056 - }
4057 -
4058 - // Update the role restriction in the database
4059 - global $wpdb;
4060 -
4061 - // Check if using Pinecone
4062 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4063 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4064 -
4065 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4066 - // Update Pinecone role restriction
4067 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4068 - $vector_id = md5($source_url);
4069 -
4070 - $wpdb->replace(
4071 - $roles_table,
4072 - array(
4073 - 'vector_id' => $vector_id,
4074 - 'role_restriction' => $highest_role,
4075 - 'updated_at' => current_time('mysql')
4076 - ),
4077 - array('%s', '%s', '%s')
4078 - );
4079 - } else {
4080 - // Update WordPress DB
4081 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4082 -
4083 - $wpdb->update(
4084 - $table_name,
4085 - array('role_restriction' => $highest_role),
4086 - array('source_url' => $source_url),
4087 - array('%s'),
4088 - array('%s')
4089 - );
4090 - }
4091 -}
4092 -
4093 -public function mxchat_get_public_post_types() {
4094 - // Get all public post types
4095 - $post_types = get_post_types(array('public' => true), 'objects');
4096 - $post_type_options = array();
4097 -
4098 - foreach ($post_types as $post_type) {
4099 - $post_type_options[$post_type->name] = $post_type->label;
4100 - }
4101 -
4102 - // Also include common forum/community post types that might not be marked as public
4103 - $additional_types = array(
4104 - 'topic' => 'Forum Topics (bbPress)',
4105 - 'reply' => 'Forum Replies (bbPress)',
4106 - 'forum' => 'Forums (bbPress)',
4107 - 'wpforo_topic' => 'wpForo Topics',
4108 - 'wpforo_post' => 'wpForo Posts'
4109 - );
4110 -
4111 - foreach ($additional_types as $type_name => $type_label) {
4112 - if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
4113 - $post_type_options[$type_name] = $type_label;
4114 - }
4115 - }
4116 -
4117 - return $post_type_options;
4118 -}
4119 -
4120 -/**
4121 - * Retrieves processed content from Pinecone API
4122 - */
4123 -public function mxchat_get_pinecone_processed_content($pinecone_options) {
4124 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4125 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4126 -
4127 - if (empty($api_key) || empty($host)) {
4128 - return array();
4129 - }
4130 -
4131 - $pinecone_data = array();
4132 -
4133 - try {
4134 - // Always get fresh data from Pinecone
4135 - $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
4136 -
4137 - // Method 2: Final fallback - try stats endpoint (if available)
4138 - if (empty($pinecone_data)) {
4139 - $stats_url = "https://{$host}/describe_index_stats";
4140 -
4141 - $response = wp_remote_post($stats_url, array(
4142 - 'headers' => array(
4143 - 'Api-Key' => $api_key,
4144 - 'Content-Type' => 'application/json'
4145 - ),
4146 - 'body' => json_encode(array()),
4147 - 'timeout' => 30
4148 - ));
4149 -
4150 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4151 - $body = wp_remote_retrieve_body($response);
4152 - $stats_data = json_decode($body, true);
4153 - }
4154 - }
4155 -
4156 - } catch (Exception $e) {
4157 - // Log error but return fresh data only
4158 - }
4159 -
4160 - return $pinecone_data;
4161 -}
4162 -public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
4163 - //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
4164 -
4165 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4166 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4167 -
4168 - if (empty($api_key) || empty($host) || empty($vector_ids)) {
4169 - //error_log('DEBUG: Missing parameters for fetch by IDs');
4170 - return array();
4171 - }
4172 -
4173 - try {
4174 - $fetch_url = "https://{$host}/vectors/fetch";
4175 - //error_log('DEBUG: Fetch URL: ' . $fetch_url);
4176 - //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
4177 -
4178 - // Pinecone fetch API allows fetching specific vectors by ID
4179 - $fetch_data = array(
4180 - 'ids' => array_values($vector_ids)
4181 - );
4182 -
4183 - $response = wp_remote_post($fetch_url, array(
4184 - 'headers' => array(
4185 - 'Api-Key' => $api_key,
4186 - 'Content-Type' => 'application/json'
4187 - ),
4188 - 'body' => json_encode($fetch_data),
4189 - 'timeout' => 30
4190 - ));
4191 -
4192 - if (is_wp_error($response)) {
4193 - //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
4194 - return array();
4195 - }
4196 -
4197 - $response_code = wp_remote_retrieve_response_code($response);
4198 - //error_log('DEBUG: Fetch response code: ' . $response_code);
4199 -
4200 - if ($response_code !== 200) {
4201 - $error_body = wp_remote_retrieve_body($response);
4202 - //error_log('DEBUG: Fetch failed with body: ' . $error_body);
4203 - return array();
4204 - }
4205 -
4206 - $body = wp_remote_retrieve_body($response);
4207 - $data = json_decode($body, true);
4208 -
4209 - //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
4210 -
4211 - if (!isset($data['vectors'])) {
4212 - //error_log('DEBUG: No vectors key in response');
4213 - return array();
4214 - }
4215 -
4216 - $processed_data = array();
4217 -
4218 - foreach ($data['vectors'] as $vector_id => $vector_data) {
4219 - $metadata = $vector_data['metadata'] ?? array();
4220 - $source_url = $metadata['source_url'] ?? '';
4221 -
4222 - if (!empty($source_url)) {
4223 - $post_id = url_to_postid($source_url);
4224 - if ($post_id) {
4225 - $created_at = $metadata['created_at'] ?? '';
4226 - $processed_date = 'Recently';
4227 -
4228 - if (!empty($created_at)) {
4229 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4230 - if ($timestamp) {
4231 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4232 - }
4233 - }
4234 -
4235 - $processed_data[$post_id] = array(
4236 - 'db_id' => $vector_id,
4237 - 'processed_date' => $processed_date,
4238 - 'url' => $source_url,
4239 - 'source' => 'pinecone',
4240 - 'timestamp' => $timestamp ?? current_time('timestamp')
4241 - );
4242 - }
4243 - }
4244 - }
4245 -
4246 - //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
4247 - return $processed_data;
4248 -
4249 - } catch (Exception $e) {
4250 - //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
4251 - return array();
4252 - }
4253 -}
4254 -
4255 -/**
4256 - * Get embedding dimensions based on the selected model.
4257 - */
4258 -private function mxchat_get_embedding_dimensions() {
4259 - $options = get_option('mxchat_options', array());
4260 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4261 -
4262 - $model_dimensions = array(
4263 - 'text-embedding-ada-002' => 1536,
4264 - 'text-embedding-3-small' => 1536,
4265 - 'text-embedding-3-large' => 3072,
4266 - 'voyage-2' => 1024,
4267 - 'voyage-large-2' => 1536,
4268 - 'voyage-3-large' => 2048,
4269 - 'gemini-embedding-001' => 1536,
4270 - );
4271 -
4272 - if (strpos($selected_model, 'voyage-3-large') === 0) {
4273 - $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
4274 - return intval($custom_dimensions);
4275 - }
4276 -
4277 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4278 - $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
4279 - return intval($custom_dimensions);
4280 - }
4281 -
4282 - return $model_dimensions[$selected_model] ?? 1536;
4283 -}
4284 -
4285 -/**
4286 - * Scan Pinecone for processed content
4287 - */
4288 -public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4289 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4290 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4291 -
4292 - if (empty($api_key) || empty($host)) {
4293 - return array();
4294 - }
4295 -
4296 - try {
4297 - // Use multiple random vectors to get better coverage
4298 - $all_matches = array();
4299 - $seen_ids = array();
4300 -
4301 - // Get correct dimensions for the configured embedding model
4302 - $dimensions = $this->mxchat_get_embedding_dimensions();
4303 -
4304 - // Try 3 different random vectors to get better coverage
4305 - for ($i = 0; $i < 3; $i++) {
4306 - $query_url = "https://{$host}/query";
4307 -
4308 - // Generate a random unit vector instead of zeros
4309 - $random_vector = array();
4310 - for ($j = 0; $j < $dimensions; $j++) {
4311 - $random_vector[] = (rand(-1000, 1000) / 1000.0);
4312 - }
4313 -
4314 - // Normalize the vector to unit length
4315 - $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
4316 - if ($magnitude > 0) {
4317 - $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
4318 - }
4319 -
4320 - $query_data = array(
4321 - 'includeMetadata' => true,
4322 - 'includeValues' => false,
4323 - 'topK' => 10000,
4324 - 'vector' => $random_vector
4325 - );
4326 -
4327 - $response = wp_remote_post($query_url, array(
4328 - 'headers' => array(
4329 - 'Api-Key' => $api_key,
4330 - 'Content-Type' => 'application/json'
4331 - ),
4332 - 'body' => json_encode($query_data),
4333 - 'timeout' => 30
4334 - ));
4335 -
4336 - if (is_wp_error($response)) {
4337 - continue;
4338 - }
4339 -
4340 - $response_code = wp_remote_retrieve_response_code($response);
4341 -
4342 - if ($response_code !== 200) {
4343 - continue;
4344 - }
4345 -
4346 - $body = wp_remote_retrieve_body($response);
4347 - $data = json_decode($body, true);
4348 -
4349 - if (isset($data['matches'])) {
4350 - foreach ($data['matches'] as $match) {
4351 - $match_id = $match['id'] ?? '';
4352 - if (!empty($match_id) && !isset($seen_ids[$match_id])) {
4353 - $all_matches[] = $match;
4354 - $seen_ids[$match_id] = true;
4355 - }
4356 - }
4357 - }
4358 - }
4359 -
4360 - // Convert matches to processed data format, grouping by URL to count chunks
4361 - $processed_data = array();
4362 - $url_chunk_counts = array();
4363 -
4364 - foreach ($all_matches as $match) {
4365 - $metadata = $match['metadata'] ?? array();
4366 - $source_url = $metadata['source_url'] ?? '';
4367 - $match_id = $match['id'] ?? '';
4368 -
4369 - if (!empty($source_url) && !empty($match_id)) {
4370 - $post_id = url_to_postid($source_url);
4371 - if ($post_id) {
4372 - // Count chunks per post_id
4373 - if (!isset($url_chunk_counts[$post_id])) {
4374 - $url_chunk_counts[$post_id] = 0;
4375 - }
4376 - $url_chunk_counts[$post_id]++;
4377 -
4378 - $created_at = $metadata['created_at'] ?? '';
4379 - $processed_date = 'Recently';
4380 -
4381 - if (!empty($created_at)) {
4382 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4383 - if ($timestamp) {
4384 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4385 - }
4386 - }
4387 -
4388 - // Only store if not already set, or update with newer timestamp
4389 - if (!isset($processed_data[$post_id]) ||
4390 - ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
4391 - $processed_data[$post_id] = array(
4392 - 'db_id' => $match_id,
4393 - 'processed_date' => $processed_date,
4394 - 'url' => $source_url,
4395 - 'source' => 'pinecone',
4396 - 'timestamp' => $timestamp ?? current_time('timestamp')
4397 - );
4398 - }
4399 - }
4400 - }
4401 - }
4402 -
4403 - // Add chunk counts to processed data
4404 - foreach ($url_chunk_counts as $post_id => $chunk_count) {
4405 - if (isset($processed_data[$post_id])) {
4406 - $processed_data[$post_id]['chunk_count'] = $chunk_count;
4407 - }
4408 - }
4409 -
4410 - return $processed_data;
4411 -
4412 - } catch (Exception $e) {
4413 - return array();
4414 - }
4415 -}
4416 -/**
4417 - * Generate embeddings from input text for MXChat with bot support
4418 - */
4419 -private function mxchat_generate_embedding($text, $bot_id = 'default') {
4420 - // Enable detailed logging for debugging
4421 - //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
4422 - //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
4423 -
4424 - // Get bot-specific options
4425 - $bot_options = $this->get_bot_options($bot_id);
4426 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4427 -
4428 - // Opt-in: when the custom provider is selected for embeddings, index through
4429 - // the same custom endpoint the query path uses so stored vectors and query
4430 - // vectors share a model. Returns the vector array on success, or an error
4431 - // string on failure (this function's existing failure contract).
4432 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4433 - if (!class_exists('MxChat_Utils')) {
4434 - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4435 - }
4436 - return MxChat_Utils::generate_embedding_custom($text, $options);
4437 - }
4438 -
4439 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4440 - //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4441 -
4442 - // Determine provider and endpoint
4443 - if (strpos($selected_model, 'voyage') === 0) {
4444 - $api_key = $options['voyage_api_key'] ?? '';
4445 - $endpoint = 'https://api.voyageai.com/v1/embeddings';
4446 - $provider_name = 'Voyage AI';
4447 - //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
4448 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4449 - $api_key = $options['gemini_api_key'] ?? '';
4450 - $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4451 - $provider_name = 'Google Gemini';
4452 - //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
4453 - } else {
4454 - $api_key = $options['api_key'] ?? '';
4455 - $endpoint = 'https://api.openai.com/v1/embeddings';
4456 - $provider_name = 'OpenAI';
4457 - //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
4458 - }
4459 -
4460 - //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
4461 -
4462 - if (empty($api_key)) {
4463 - $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
4464 - //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
4465 - return $error_message;
4466 - }
4467 -
4468 - // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
4469 - $estimated_tokens = ceil(str_word_count($text) / 0.75);
4470 - //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
4471 -
4472 - if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
4473 - //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
4474 - // Consider truncating text here
4475 - }
4476 -
4477 - // Prepare request body based on provider
4478 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4479 - // Gemini API format
4480 - $request_body = array(
4481 - 'model' => 'models/' . $selected_model,
4482 - 'content' => array(
4483 - 'parts' => array(
4484 - array('text' => $text)
4485 - )
4486 - )
4487 - );
4488 -
4489 - // Set output dimensionality to 1536 for consistency with other models
4490 - $request_body['outputDimensionality'] = 1536;
4491 - } else {
4492 - // OpenAI/Voyage API format
4493 - $request_body = array(
4494 - 'model' => $selected_model,
4495 - 'input' => $text
4496 - );
4497 -
4498 - // Add output_dimension for voyage-3-large model
4499 - if ($selected_model === 'voyage-3-large') {
4500 - $request_body['output_dimension'] = 2048;
4501 - }
4502 - }
4503 -
4504 - //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
4505 -
4506 - // Prepare headers based on provider
4507 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4508 - // Gemini uses API key as query parameter
4509 - $endpoint .= '?key=' . $api_key;
4510 - $headers = array(
4511 - 'Content-Type' => 'application/json'
4512 - );
4513 - } else {
4514 - // OpenAI/Voyage use Bearer token
4515 - $headers = array(
4516 - 'Authorization' => 'Bearer ' . $api_key,
4517 - 'Content-Type' => 'application/json'
4518 - );
4519 - }
4520 -
4521 - // Make API request
4522 - //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
4523 - $response = wp_remote_post($endpoint, array(
4524 - 'body' => wp_json_encode($request_body),
4525 - 'headers' => $headers,
4526 - 'timeout' => 60 // Increased timeout for large inputs
4527 - ));
4528 -
4529 - // Handle wp_remote_post errors
4530 - if (is_wp_error($response)) {
4531 - $error_message = $response->get_error_message();
4532 - //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
4533 - return 'Connection error: ' . $error_message;
4534 - }
4535 -
4536 - // Get and check HTTP response code
4537 - $http_code = wp_remote_retrieve_response_code($response);
4538 - //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
4539 -
4540 - if ($http_code !== 200) {
4541 - $error_body = wp_remote_retrieve_body($response);
4542 - //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
4543 -
4544 - // Try to parse error for more details
4545 - $error_json = json_decode($error_body, true);
4546 - if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
4547 - $error_type = $error_json['error']['type'] ?? 'unknown';
4548 - $error_message = $error_json['error']['message'] ?? 'No message';
4549 - //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
4550 - //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
4551 -
4552 - // Customize error message for common API errors
4553 - if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
4554 - $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
4555 - } elseif ($error_type === 'authentication_error') {
4556 - $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
4557 - }
4558 -
4559 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4560 - return $error_message;
4561 - }
4562 -
4563 - $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
4564 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4565 - return $error_message;
4566 - }
4567 -
4568 - // Parse response body
4569 - $response_body = wp_remote_retrieve_body($response);
4570 - //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
4571 -
4572 - $response_data = json_decode($response_body, true);
4573 -
4574 - if (json_last_error() !== JSON_ERROR_NONE) {
4575 - $error = json_last_error_msg();
4576 - //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
4577 - //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
4578 - return "Failed to parse API response: $error";
4579 - }
4580 -
4581 - // Handle different response formats based on provider
4582 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4583 - // Gemini API response format
4584 - if (isset($response_data['embedding']['values'])) {
4585 - $embedding_dimensions = count($response_data['embedding']['values']);
4586 - //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
4587 -
4588 - // Check if embedding dimensions are as expected (should be 1536)
4589 - if ($embedding_dimensions !== 1536) {
4590 - //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
4591 - }
4592 -
4593 - return $response_data['embedding']['values'];
4594 - } else {
4595 - //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
4596 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4597 -
4598 - if (isset($response_data['error'])) {
4599 - $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
4600 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4601 - return $error_message;
4602 - }
4603 -
4604 - $error_message = "Invalid Gemini API response format: No embedding found";
4605 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4606 - return $error_message;
4607 - }
4608 - } else {
4609 - // OpenAI/Voyage API response format
4610 - if (isset($response_data['data'][0]['embedding'])) {
4611 - $embedding_dimensions = count($response_data['data'][0]['embedding']);
4612 - //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
4613 -
4614 - // Check if embedding dimensions are as expected
4615 - if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
4616 - ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
4617 - //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
4618 - }
4619 -
4620 - return $response_data['data'][0]['embedding'];
4621 - } else {
4622 - //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
4623 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4624 -
4625 - if (isset($response_data['error'])) {
4626 - $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
4627 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4628 - return $error_message;
4629 - }
4630 -
4631 - $error_message = "Invalid API response format: No embedding found";
4632 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4633 - return $error_message;
4634 - }
4635 - }
4636 -}
4637 -
4638 -/**
4639 - * Get bot-specific options for multi-bot functionality
4640 - * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
4641 - */
4642 -private function get_bot_options($bot_id = 'default') {
4643 - //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
4644 -
4645 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4646 - //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
4647 - return array();
4648 - }
4649 -
4650 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
4651 -
4652 - if (!empty($bot_options)) {
4653 - //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
4654 - if (isset($bot_options['similarity_threshold'])) {
4655 - //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
4656 - }
4657 - }
4658 -
4659 - return is_array($bot_options) ? $bot_options : array();
4660 -}
4661 -
4662 -/**
4663 - * Get bot-specific Pinecone configuration
4664 - * Used in the knowledge retrieval functions
4665 - */
4666 -// Also add debugging to your get_bot_pinecone_config function
4667 -private function get_bot_pinecone_config($bot_id = 'default') {
4668 - //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4669 -
4670 - // If default bot or multi-bot add-on not active, use default Pinecone config
4671 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4672 - //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4673 - $addon_options = get_option('mxchat_pinecone_addon_options', array());
4674 - $config = array(
4675 - 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4676 - 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4677 - 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4678 - 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4679 - );
4680 - //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4681 - return $config;
4682 - }
4683 -
4684 - //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4685 -
4686 - // Hook for multi-bot add-on to provide bot-specific Pinecone config
4687 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4688 -
4689 - if (!empty($bot_pinecone_config)) {
4690 - //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4691 - //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4692 - //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4693 - //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4694 - } else {
4695 - //error_log("MXCHAT DEBUG: Filter returned empty config!");
4696 - }
4697 -
4698 - return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4699 -}
4700 -
4701 -
4702 -public function mxchat_ajax_dismiss_completed_status() {
4703 - try {
4704 - // Verify the request
4705 - check_ajax_referer('mxchat_status_nonce', 'nonce');
4706 -
4707 - if (!current_user_can('manage_options')) {
4708 - wp_send_json_error('Unauthorized access');
4709 - exit;
4710 - }
4711 -
4712 - $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
4713 -
4714 - if ($card_type === 'pdf') {
4715 - // Clear PDF status
4716 - $pdf_url = get_transient('mxchat_last_pdf_url');
4717 - if ($pdf_url) {
4718 - delete_transient('mxchat_pdf_status_' . md5($pdf_url));
4719 - delete_transient('mxchat_last_pdf_url');
4720 - }
4721 - } elseif ($card_type === 'sitemap') {
4722 - // Clear sitemap status
4723 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
4724 - if ($sitemap_url) {
4725 - delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
4726 - delete_transient('mxchat_last_sitemap_url');
4727 - }
4728 - }
4729 -
4730 - wp_send_json_success(array('message' => 'Status dismissed successfully'));
4731 -
4732 - } catch (Exception $e) {
4733 - wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
4734 - }
4735 -}
4736 -
4737 -/**
4738 - * Render completed status cards on page load
4739 - * This ensures completed processing status persists through page refreshes
4740 - */
4741 -public function mxchat_render_completed_status_cards() {
4742 - $output = '';
4743 -
4744 - // Check for completed PDF status
4745 - $pdf_url = get_transient('mxchat_last_pdf_url');
4746 - if ($pdf_url) {
4747 - $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
4748 - if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
4749 - $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
4750 - }
4751 - }
4752 -
4753 - // Check for completed sitemap status
4754 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
4755 - if ($sitemap_url) {
4756 - $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
4757 - if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
4758 - $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
4759 - }
4760 - }
4761 -
4762 - return $output;
4763 -}
4764 -
4765 -/**
4766 - * Render PDF status card HTML
4767 - */
4768 -private function mxchat_render_pdf_status_card($status, $pdf_url) {
4769 - $html = '<div class="mxchat-status-card" data-card-type="pdf">';
4770 - $html .= '<div class="mxchat-status-header">';
4771 - $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
4772 -
4773 - // Add dismiss button for completed status
4774 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
4775 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4776 - }
4777 -
4778 - // Process Batch button for processing status
4779 - if ($status['status'] === 'processing') {
4780 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
4781 - data-process-type="pdf"
4782 - data-url="' . esc_attr($pdf_url) . '">
4783 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4784 - }
4785 -
4786 - // Add status badges
4787 - if ($status['status'] === 'error') {
4788 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4789 - } elseif ($status['status'] === 'complete') {
4790 - if ($status['failed_pages'] > 0) {
4791 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4792 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
4793 - } else {
4794 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4795 - }
4796 - }
4797 -
4798 - $html .= '</div>'; // End header
4799 -
4800 - // Progress bar
4801 - $html .= '<div class="mxchat-progress-bar">';
4802 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4803 - $html .= '</div>';
4804 -
4805 - // Status details
4806 - $html .= '<div class="mxchat-status-details">';
4807 - $html .= '<p>' . sprintf(
4808 - esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
4809 - $status['processed_pages'],
4810 - $status['total_pages'],
4811 - $status['percentage']
4812 - ) . '</p>';
4813 -
4814 - // Show failed pages count if any
4815 - if ($status['failed_pages'] > 0) {
4816 - $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
4817 - }
4818 -
4819 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4820 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4821 -
4822 - // Add completion summary if available AND it's an array
4823 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4824 - $summary = $status['completion_summary'];
4825 - $html .= '<div class="mxchat-completion-summary">';
4826 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4827 - $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
4828 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
4829 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
4830 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4831 - $html .= '</div>';
4832 - }
4833 -
4834 - // Add failed pages list if any AND it's an array
4835 - if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
4836 - $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
4837 - }
4838 -
4839 - // Add error message if any
4840 - if (isset($status['error']) && !empty($status['error'])) {
4841 - $html .= '<div class="mxchat-error-notice">';
4842 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4843 - $html .= '</div>';
4844 - }
4845 -
4846 - $html .= '</div>'; // End details
4847 - $html .= '</div>'; // End card
4848 -
4849 - return $html;
4850 -}
4851 -/**
4852 - * Render sitemap status card HTML
4853 - */
4854 -private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
4855 - $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
4856 - $html .= '<div class="mxchat-status-header">';
4857 - $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
4858 -
4859 - // Add dismiss button for completed status
4860 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
4861 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4862 - }
4863 -
4864 - // Process Batch button for processing status
4865 - if ($status['status'] === 'processing') {
4866 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
4867 - data-process-type="sitemap"
4868 - data-url="' . esc_attr($sitemap_url) . '">
4869 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4870 - }
4871 -
4872 - // Add status badges
4873 - if ($status['status'] === 'error') {
4874 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4875 - } elseif ($status['status'] === 'complete') {
4876 - if ($status['failed_urls'] > 0) {
4877 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4878 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
4879 - } else {
4880 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4881 - }
4882 - }
4883 -
4884 - $html .= '</div>'; // End header
4885 -
4886 - // Progress bar
4887 - $html .= '<div class="mxchat-progress-bar">';
4888 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4889 - $html .= '</div>';
4890 -
4891 - // Status details
4892 - $html .= '<div class="mxchat-status-details">';
4893 - $html .= '<p>' . sprintf(
4894 - esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
4895 - $status['processed_urls'],
4896 - $status['total_urls'],
4897 - $status['percentage']
4898 - ) . '</p>';
4899 -
4900 - // Show failed URLs count if any
4901 - if ($status['failed_urls'] > 0) {
4902 - $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
4903 - }
4904 -
4905 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4906 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4907 -
4908 - // Add completion summary if available AND it's an array
4909 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4910 - $summary = $status['completion_summary'];
4911 - $html .= '<div class="mxchat-completion-summary">';
4912 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4913 - $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
4914 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
4915 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
4916 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4917 - $html .= '</div>';
4918 - }
4919 -
4920 - // Add error messages if any (but not the failed URLs list)
4921 - if (!empty($status['error']) || !empty($status['last_error'])) {
4922 - $html .= '<div class="mxchat-error-notice">';
4923 -
4924 - if (!empty($status['error'])) {
4925 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4926 - }
4927 -
4928 - if (!empty($status['last_error'])) {
4929 - $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
4930 - }
4931 -
4932 - $html .= '</div>';
4933 - }
4934 -
4935 - $html .= '</div>'; // End details
4936 - $html .= '</div>'; // End card
4937 -
4938 - return $html;
4939 -}
4940 -
4941 -
4942 -/**
4943 - * Render failed pages list
4944 - */
4945 -private function mxchat_render_failed_pages_list($failed_pages_list) {
4946 - // Validate that $failed_pages_list is an array and not empty
4947 - if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
4948 - return '';
4949 - }
4950 -
4951 - $html = '<div class="mxchat-error-notice">';
4952 - $html .= '<div class="mxchat-failed-pages-container">';
4953 - $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
4954 - $html .= '<details>';
4955 - $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
4956 - $html .= '<div class="mxchat-failed-pages-list">';
4957 -
4958 - // Create table for failed pages
4959 - $html .= '<table class="widefat striped">';
4960 - $html .= '<thead><tr>';
4961 - $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
4962 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4963 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4964 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4965 - $html .= '</tr></thead><tbody>';
4966 -
4967 - // Sort failed pages by most recent
4968 - $sorted_failed_pages = $failed_pages_list;
4969 - usort($sorted_failed_pages, function($a, $b) {
4970 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4971 - });
4972 -
4973 - foreach ($sorted_failed_pages as $item) {
4974 - // Ensure $item is an array before accessing its elements
4975 - if (!is_array($item)) {
4976 - continue;
4977 - }
4978 -
4979 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4980 - $html .= '<tr>';
4981 - $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
4982 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4983 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4984 - $html .= '<td>' . esc_html($time_ago) . '</td>';
4985 - $html .= '</tr>';
4986 - }
4987 -
4988 - $html .= '</tbody></table>';
4989 - $html .= '</div></details></div></div>';
4990 -
4991 - return $html;
4992 -}
4993 -
4994 -/**
4995 - * Render failed URLs list
4996 - */
4997 -private function mxchat_render_failed_urls_list($failed_urls_list) {
4998 - // Validate that $failed_urls_list is an array and not empty
4999 - if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
5000 - return '';
5001 - }
5002 -
5003 - $html = '<div class="mxchat-failed-urls-container">';
5004 - $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
5005 - $html .= '<details>';
5006 - $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
5007 - $html .= '<div class="mxchat-failed-urls-list">';
5008 -
5009 - // Create table for failed URLs
5010 - $html .= '<table class="widefat striped">';
5011 - $html .= '<thead><tr>';
5012 - $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
5013 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5014 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5015 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5016 - $html .= '</tr></thead><tbody>';
5017 -
5018 - // Sort failed URLs by most recent
5019 - $sorted_failed_urls = $failed_urls_list;
5020 - usort($sorted_failed_urls, function($a, $b) {
5021 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5022 - });
5023 -
5024 - // Show up to 50 failed URLs
5025 - $display_urls = array_slice($sorted_failed_urls, 0, 50);
5026 -
5027 - foreach ($display_urls as $item) {
5028 - // Ensure $item is an array before accessing its elements
5029 - if (!is_array($item)) {
5030 - continue;
5031 - }
5032 -
5033 - $url = $item['url'] ?? '';
5034 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5035 -
5036 - // Truncate URL for display
5037 - $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
5038 -
5039 - $html .= '<tr>';
5040 - $html .= '<td style="word-break: break-all;">';
5041 - if (!empty($url)) {
5042 - $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
5043 - } else {
5044 - $html .= esc_html__('Unknown URL', 'mxchat');
5045 - }
5046 - $html .= '</td>';
5047 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5048 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5049 - $html .= '<td>' . esc_html($time_ago) . '</td>';
5050 - $html .= '</tr>';
5051 - }
5052 -
5053 - $html .= '</tbody></table>';
5054 -
5055 - if (count($failed_urls_list) > 50) {
5056 - $html .= '<div class="mxchat-failed-urls-more">+ ' .
5057 - (count($failed_urls_list) - 50) .
5058 - ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
5059 - }
5060 -
5061 - $html .= '</div></details></div>';
5062 -
5063 - return $html;
5064 -}
5065 -
5066 -/**
5067 - * Get all ACF fields for a specific post, excluding any fields the user has disabled
5068 - */
5069 -public function mxchat_get_acf_fields_for_post($post_id) {
5070 - if (!function_exists('get_fields')) {
5071 - return array();
5072 - }
5073 -
5074 - $fields = get_fields($post_id);
5075 - if (!$fields || !is_array($fields)) {
5076 - return array();
5077 - }
5078 -
5079 - // Get excluded fields from settings
5080 - $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5081 - if (!empty($excluded_fields) && is_array($excluded_fields)) {
5082 - foreach ($excluded_fields as $excluded_field) {
5083 - if (isset($fields[$excluded_field])) {
5084 - unset($fields[$excluded_field]);
5085 - }
5086 - }
5087 - }
5088 -
5089 - return $fields;
5090 -}
5091 -
5092 -/**
5093 - * Get all registered ACF field groups and their fields for the settings UI
5094 - */
5095 -public function mxchat_get_all_acf_fields() {
5096 - if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5097 - return array();
5098 - }
5099 -
5100 - $all_fields = array();
5101 - $field_groups = acf_get_field_groups();
5102 -
5103 - if (!empty($field_groups)) {
5104 - foreach ($field_groups as $group) {
5105 - $group_fields = acf_get_fields($group['key']);
5106 - if (!empty($group_fields)) {
5107 - $all_fields[$group['title']] = array();
5108 - foreach ($group_fields as $field) {
5109 - $all_fields[$group['title']][] = array(
5110 - 'name' => $field['name'],
5111 - 'label' => $field['label'],
5112 - 'type' => $field['type']
5113 - );
5114 - }
5115 - }
5116 - }
5117 - }
5118 -
5119 - return $all_fields;
5120 -}
5121 -
5122 -/**
5123 - * Get whitelisted custom post meta for a given post
5124 - * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
5125 - */
5126 -public function mxchat_get_whitelisted_post_meta($post_id) {
5127 - $whitelist = get_option('mxchat_custom_meta_whitelist', '');
5128 -
5129 - if (empty($whitelist)) {
5130 - return array();
5131 - }
5132 -
5133 - // Parse the whitelist - one meta key per line
5134 - $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
5135 -
5136 - if (empty($meta_keys)) {
5137 - return array();
5138 - }
5139 -
5140 - $result = array();
5141 -
5142 - foreach ($meta_keys as $key) {
5143 - // Skip empty keys
5144 - if (empty($key)) {
5145 - continue;
5146 - }
5147 -
5148 - $value = get_post_meta($post_id, $key, true);
5149 -
5150 - // Only include non-empty string values
5151 - if (!empty($value) && is_string($value)) {
5152 - $result[$key] = $value;
5153 - } elseif (!empty($value) && is_array($value)) {
5154 - // Handle array values by joining them
5155 - $flat_value = $this->mxchat_flatten_meta_array($value);
5156 - if (!empty($flat_value)) {
5157 - $result[$key] = $flat_value;
5158 - }
5159 - }
5160 - }
5161 -
5162 - return $result;
5163 -}
5164 -
5165 -/**
5166 - * Flatten array meta values into a readable string
5167 - */
5168 -private function mxchat_flatten_meta_array($array, $depth = 0) {
5169 - if ($depth > 3) {
5170 - return ''; // Prevent infinite recursion
5171 - }
5172 -
5173 - $parts = array();
5174 -
5175 - foreach ($array as $key => $value) {
5176 - if (is_string($value) && !empty($value)) {
5177 - $parts[] = $value;
5178 - } elseif (is_array($value)) {
5179 - $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
5180 - if (!empty($nested)) {
5181 - $parts[] = $nested;
5182 - }
5183 - }
5184 - }
5185 -
5186 - return implode(', ', $parts);
5187 -}
5188 -
5189 -/**
5190 - * Format ACF field values for content extraction
5191 - */
5192 -public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
5193 - if (empty($value)) {
5194 - return '';
5195 - }
5196 -
5197 - // Handle WP_Post objects first (THIS IS THE KEY FIX)
5198 - if ($value instanceof WP_Post) {
5199 - return $value->post_title ?: '';
5200 - }
5201 -
5202 - // Handle other WP objects
5203 - if (is_object($value)) {
5204 - if (isset($value->post_title)) {
5205 - return $value->post_title;
5206 - } elseif (isset($value->display_name)) {
5207 - return $value->display_name;
5208 - } elseif (isset($value->name)) {
5209 - return $value->name;
5210 - } elseif (method_exists($value, '__toString')) {
5211 - try {
5212 - return (string) $value;
5213 - } catch (Exception $e) {
5214 - return '';
5215 - }
5216 - }
5217 - // For any other objects, return empty string
5218 - return '';
5219 - }
5220 -
5221 - // Handle different ACF field types
5222 - if (is_array($value)) {
5223 - // Check if it's an image/file field
5224 - if (isset($value['url'])) {
5225 - // Image field - return alt text, title, or caption
5226 - if (!empty($value['alt'])) {
5227 - return $value['alt'];
5228 - } elseif (!empty($value['title'])) {
5229 - return $value['title'];
5230 - } elseif (!empty($value['caption'])) {
5231 - return $value['caption'];
5232 - } else {
5233 - return ''; // Don't include just the URL
5234 - }
5235 - }
5236 -
5237 - // Check if it's a post object or relationship field
5238 - if (isset($value['post_title'])) {
5239 - return $value['post_title'];
5240 - }
5241 -
5242 - // Check if it's a user field
5243 - if (isset($value['display_name'])) {
5244 - return $value['display_name'];
5245 - }
5246 -
5247 - // Check if it's a taxonomy term
5248 - if (isset($value['name']) && isset($value['taxonomy'])) {
5249 - return $value['name'];
5250 - }
5251 -
5252 - // Check if it's a select field with label
5253 - if (isset($value['label'])) {
5254 - return $value['label'];
5255 - }
5256 -
5257 - // Check for repeater field or flexible content
5258 - if (is_numeric(key($value))) {
5259 - $sub_values = array();
5260 - foreach ($value as $sub_item) {
5261 - if (is_array($sub_item)) {
5262 - // For repeater/flexible content, extract text values
5263 - $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
5264 - if (!empty($sub_text)) {
5265 - $sub_values[] = $sub_text;
5266 - }
5267 - } elseif ($sub_item instanceof WP_Post) {
5268 - // Handle WP_Post objects in arrays
5269 - $sub_values[] = $sub_item->post_title ?: '';
5270 - } else {
5271 - $sub_values[] = (string) $sub_item;
5272 - }
5273 - }
5274 - return implode(', ', array_filter($sub_values));
5275 - }
5276 -
5277 - // For other arrays, try to extract meaningful text
5278 - $text_values = array();
5279 - foreach ($value as $key => $val) {
5280 - if (is_string($val) && !empty(trim($val))) {
5281 - $text_values[] = trim($val);
5282 - } elseif ($val instanceof WP_Post) {
5283 - // Handle WP_Post objects in associative arrays
5284 - $text_values[] = $val->post_title ?: '';
5285 - } elseif (is_array($val) && isset($val['post_title'])) {
5286 - $text_values[] = $val['post_title'];
5287 - } elseif (is_array($val) && isset($val['name'])) {
5288 - $text_values[] = $val['name'];
5289 - }
5290 - }
5291 -
5292 - return implode(', ', array_filter($text_values));
5293 - }
5294 -
5295 - // Handle boolean values
5296 - if (is_bool($value)) {
5297 - return $value ? 'Yes' : 'No';
5298 - }
5299 -
5300 - // Handle numeric values
5301 - if (is_numeric($value)) {
5302 - return (string) $value;
5303 - }
5304 -
5305 - // Handle string values
5306 - if (is_string($value)) {
5307 - return trim($value);
5308 - }
5309 -
5310 - // For anything else that we can't handle, return empty string
5311 - // This prevents the "Object could not be converted to string" error
5312 - return '';
5313 -}
5314 -
5315 -/**
5316 - * Extract text from complex ACF array structures
5317 - */
5318 -private function mxchat_extract_text_from_acf_array($array) {
5319 - if (!is_array($array)) {
5320 - return '';
5321 - }
5322 -
5323 - $text_parts = array();
5324 -
5325 - foreach ($array as $key => $value) {
5326 - if (is_string($value) && !empty(trim($value))) {
5327 - // Skip keys that are likely to be IDs or technical values
5328 - if (!is_numeric($value) || strlen($value) > 10) {
5329 - $text_parts[] = trim($value);
5330 - }
5331 - } elseif ($value instanceof WP_Post) {
5332 - // Handle WP_Post objects
5333 - $text_parts[] = $value->post_title ?: '';
5334 - } elseif (is_array($value)) {
5335 - if (isset($value['post_title'])) {
5336 - $text_parts[] = $value['post_title'];
5337 - } elseif (isset($value['name'])) {
5338 - $text_parts[] = $value['name'];
5339 - } elseif (isset($value['label'])) {
5340 - $text_parts[] = $value['label'];
5341 - }
5342 - } elseif (is_object($value)) {
5343 - // Handle other objects safely
5344 - if (isset($value->post_title)) {
5345 - $text_parts[] = $value->post_title;
5346 - } elseif (isset($value->name)) {
5347 - $text_parts[] = $value->name;
5348 - } elseif (isset($value->display_name)) {
5349 - $text_parts[] = $value->display_name;
5350 - }
5351 - }
5352 - }
5353 -
5354 - return implode(', ', array_filter($text_parts));
5355 -}
5356 -
5357 -/**
5358 - * Walk an ACF field value tree and collect attachment IDs for any value that
5359 - * resolves to a PDF in the WordPress media library. Handles the three shapes
5360 - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5361 - * plain URL string), and recurses through repeater/group/flexible content.
5362 - *
5363 - * @param mixed $value The ACF field value (any depth)
5364 - * @param array $out Accumulator (passed by reference) for attachment IDs
5365 - * @param int $depth Recursion guard
5366 - */
5367 -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5368 - if ($depth > 6) {
5369 - return; // prevent runaway recursion on circular/very-deep structures
5370 - }
5371 -
5372 - if (empty($value)) {
5373 - return;
5374 - }
5375 -
5376 - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5377 - if (is_array($value)) {
5378 - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5379 - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5380 - if ($looks_like_attachment) {
5381 - $att_id = 0;
5382 - if (!empty($value['ID']) && is_numeric($value['ID'])) {
5383 - $att_id = (int) $value['ID'];
5384 - } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5385 - $att_id = (int) $value['id'];
5386 - } elseif (!empty($value['url']) && is_string($value['url'])) {
5387 - $att_id = (int) attachment_url_to_postid($value['url']);
5388 - }
5389 -
5390 - $is_pdf = false;
5391 - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5392 - $is_pdf = true;
5393 - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5394 - $is_pdf = true;
5395 - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5396 - $is_pdf = true;
5397 - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5398 - $is_pdf = true;
5399 - }
5400 -
5401 - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5402 - $out[] = $att_id;
5403 - }
5404 - // An array node that represents one attachment doesn't contain other
5405 - // attachments inside it — done with this branch.
5406 - return;
5407 - }
5408 -
5409 - // Recurse: repeater rows, flexible-content layouts, groups, etc.
5410 - foreach ($value as $sub) {
5411 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5412 - }
5413 - return;
5414 - }
5415 -
5416 - // Plain numeric attachment ID (ACF File field set to "Return: ID")
5417 - if (is_numeric($value)) {
5418 - $att_id = (int) $value;
5419 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5420 - $out[] = $att_id;
5421 - }
5422 - return;
5423 - }
5424 -
5425 - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5426 - if (is_string($value)) {
5427 - $trimmed = trim($value);
5428 - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5429 - $att_id = (int) attachment_url_to_postid($trimmed);
5430 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5431 - $out[] = $att_id;
5432 - }
5433 - }
5434 - return;
5435 - }
5436 -}
5437 -
5438 -/**
5439 - * Heuristic: does this URL/string look like a PDF reference?
5440 - * Tolerates query strings and fragments (#page=2).
5441 - */
5442 -private function mxchat_url_looks_like_pdf($url) {
5443 - if (!is_string($url) || $url === '') {
5444 - return false;
5445 - }
5446 - // Strip query + fragment before checking extension
5447 - $path = preg_replace('/[?#].*$/', '', $url);
5448 - return (bool) preg_match('/\.pdf$/i', $path);
5449 -}
5450 -
5451 -/**
5452 - * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5453 - * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5454 - * Result is cached on the attachment as post_meta keyed by file mtime so we
5455 - * only parse the same PDF once unless the file changes on disk.
5456 - *
5457 - * @param int $attachment_id
5458 - * @return string Extracted plain text, or '' on failure.
5459 - */
5460 -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5461 - $attachment_id = (int) $attachment_id;
5462 - if ($attachment_id <= 0) {
5463 - return '';
5464 - }
5465 - if (get_post_mime_type($attachment_id) !== 'application/pdf') {
5466 - return '';
5467 - }
5468 -
5469 - $pdf_path = get_attached_file($attachment_id);
5470 - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
5471 - return '';
5472 - }
5473 -
5474 - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
5475 - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
5476 - $default_max_bytes = 25 * 1024 * 1024;
5477 - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
5478 - if ($max_bytes > 0) {
5479 - $file_size = @filesize($pdf_path);
5480 - if ($file_size !== false && $file_size > $max_bytes) {
5481 - error_log(sprintf(
5482 - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
5483 - $attachment_id,
5484 - basename($pdf_path),
5485 - $file_size,
5486 - $max_bytes
5487 - ));
5488 - return '';
5489 - }
5490 - }
5491 -
5492 - $mtime = @filemtime($pdf_path);
5493 - $cache_meta_key = '_mxchat_acf_pdf_text_v1';
5494 - $cached = get_post_meta($attachment_id, $cache_meta_key, true);
5495 - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
5496 - return (string) $cached['text'];
5497 - }
5498 -
5499 - $text = '';
5500 - try {
5501 - if (function_exists('mxchat_load_pdf_parser')) {
5502 - mxchat_load_pdf_parser();
5503 - }
5504 - if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
5505 - return '';
5506 - }
5507 - $parser = new \Smalot\PdfParser\Parser();
5508 - $pdf = $parser->parseFile($pdf_path);
5509 - $pages = $pdf->getPages();
5510 - $page_texts = array();
5511 - foreach ($pages as $page) {
5512 - $page_text = '';
5513 - try {
5514 - $page_text = $page->getText();
5515 - } catch (\Exception $e) {
5516 - $page_text = '';
5517 - }
5518 - if (!empty($page_text)) {
5519 - $page_texts[] = $page_text;
5520 - }
5521 - }
5522 - $text = trim(implode("\n\n", $page_texts));
5523 - } catch (\Exception $e) {
5524 - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
5525 - return '';
5526 - } catch (\Throwable $e) {
5527 - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
5528 - return '';
5529 - }
5530 -
5531 - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
5532 - // The chunker downstream will still split this into multiple vectors.
5533 - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
5534 - if ($max_len > 0 && strlen($text) > $max_len) {
5535 - $text = substr($text, 0, $max_len);
5536 - }
5537 -
5538 - update_post_meta($attachment_id, $cache_meta_key, array(
5539 - 'mtime' => (int) $mtime,
5540 - 'text' => $text,
5541 - ));
5542 -
5543 - return $text;
5544 -}
5545 -
5546 -/**
5547 - * Handle ACF save - fires after ACF fields are saved
5548 - * This ensures ACF field data is available when syncing to knowledge base
5549 - */
5550 -public function mxchat_handle_acf_save($post_id) {
5551 - // Skip if not a valid post
5552 - if (!$post_id || $post_id === 'options') {
5553 - return;
5554 - }
5555 -
5556 - // Skip autosaves and revisions
5557 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5558 - return;
5559 - }
5560 -
5561 - $post = get_post($post_id);
5562 - if (!$post) {
5563 - return;
5564 - }
5565 -
5566 - $post_type = $post->post_type;
5567 -
5568 - // Check if sync is enabled for this post type
5569 - $should_sync = false;
5570 -
5571 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5572 - $should_sync = true;
5573 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5574 - $should_sync = true;
5575 - } else if ($post_type === 'product' && class_exists('WooCommerce')) {
5576 - // WooCommerce products - check if WooCommerce integration is enabled
5577 - $options = get_option('mxchat_options', array());
5578 - if (isset($options['enable_woocommerce_integration']) &&
5579 - ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
5580 - $should_sync = true;
5581 - }
5582 - } else {
5583 - // Check custom post types
5584 - $option_name = 'mxchat_auto_sync_' . $post_type;
5585 - if (get_option($option_name) === '1') {
5586 - $should_sync = true;
5587 - }
5588 - }
5589 -
5590 - if (!$should_sync) {
5591 - return;
5592 - }
5593 -
5594 - // Only process published posts
5595 - if ($post->post_status !== 'publish') {
5596 - return;
5597 - }
5598 -
5599 - // Check if this post has any ACF fields - if not, no need to re-sync
5600 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5601 - if (empty($acf_fields)) {
5602 - return;
5603 - }
5604 -
5605 - // Use a transient to prevent duplicate processing (post_updated may have already run)
5606 - $transient_key = 'mxchat_acf_synced_' . $post_id;
5607 - if (get_transient($transient_key)) {
5608 - return;
5609 - }
5610 - set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
5611 -
5612 - // Re-run the sync with ACF data now available
5613 - // We pass $update=true since this is effectively an update with ACF data
5614 - $this->mxchat_handle_post_update($post_id, $post, true);
5615 -}
5616 -
5617 -public function mxchat_handle_post_update($post_id, $post, $update) {
5618 - // Basic validation checks
5619 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5620 - return;
5621 - }
5622 -
5623 - $post_type = $post->post_type;
5624 -
5625 - // Check if sync is enabled for this post type
5626 - $should_sync = false;
5627 -
5628 - // Check built-in post types first
5629 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5630 - $should_sync = true;
5631 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5632 - $should_sync = true;
5633 - } else {
5634 - // Check custom post types
5635 - $option_name = 'mxchat_auto_sync_' . $post_type;
5636 - if (get_option($option_name) === '1') {
5637 - $should_sync = true;
5638 - }
5639 - }
5640 -
5641 - if (!$should_sync) {
5642 - return;
5643 - }
5644 -
5645 - // Check if we have stored the previous status and URL in our transients
5646 - $previous_status_key = 'mxchat_prev_status_' . $post_id;
5647 - $previous_status = get_transient($previous_status_key);
5648 -
5649 - $previous_url_key = 'mxchat_prev_url_' . $post_id;
5650 - $previous_url = get_transient($previous_url_key);
5651 -
5652 - // If the post was previously published but is now not published, remove from knowledge base
5653 - if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5654 - // Use the stored URL from when it was published, or fall back to current permalink
5655 - $source_url = $previous_url ?: get_permalink($post_id);
5656 -
5657 - if ($source_url) {
5658 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5659 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5660 - }
5661 -
5662 - // Clean up the transients and exit early
5663 - delete_transient($previous_status_key);
5664 - delete_transient($previous_url_key);
5665 - return;
5666 - }
5667 -
5668 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5669 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5670 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5671 - $current_url = get_permalink($post_id);
5672 - if ($current_url && $current_url !== $previous_url) {
5673 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5674 - }
5675 - }
5676 -
5677 - // Store the current status for next time (if this is an update)
5678 - if ($update) {
5679 - set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5680 -
5681 - // If the post is currently published, also store its URL
5682 - if ($post->post_status === 'publish') {
5683 - $current_url = get_permalink($post_id);
5684 - set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
5685 - }
5686 - }
5687 -
5688 - // Only process currently published content for adding/updating
5689 - if ($post->post_status === 'publish') {
5690 - // Get the source URL
5691 - $source_url = get_permalink($post_id);
5692 -
5693 - // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
5694 - $title = get_the_title($post_id);
5695 - $content = get_post_field('post_content', $post_id);
5696 - $excerpt = get_post_field('post_excerpt', $post_id);
5697 -
5698 - // Remove shortcode tags but preserve content inside them
5699 - $content = $this->strip_shortcode_tags_preserve_content($content);
5700 - $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
5701 -
5702 - // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
5703 - $content = wp_strip_all_tags($content);
5704 -
5705 - // Combine title, short description (if exists), and content
5706 - $final_content = $title . "\n\n";
5707 -
5708 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
5709 - if (!empty($excerpt)) {
5710 - $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
5711 - }
5712 -
5713 - $final_content .= $content;
5714 -
5715 - // For WooCommerce products, include pricing and product details
5716 - if ($post_type === 'product' && class_exists('WooCommerce')) {
5717 - $product = wc_get_product($post_id);
5718 -
5719 - if ($product) {
5720 - // Get pricing information
5721 - $regular_price = $product->get_regular_price();
5722 - $sale_price = $product->get_sale_price();
5723 - $price = $product->get_price();
5724 - $sku = $product->get_sku();
5725 -
5726 - // Get currency symbol
5727 - $currency_symbol = get_woocommerce_currency_symbol();
5728 -
5729 - // Add pricing information
5730 - $final_content .= "\n";
5731 - if (!empty($regular_price)) {
5732 - $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
5733 - } elseif (!empty($price)) {
5734 - $final_content .= "Price: " . $currency_symbol . $price . "\n";
5735 - }
5736 -
5737 - if (!empty($sale_price) && $sale_price !== $regular_price) {
5738 - $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5739 - }
5740 -
5741 - // Handle variable products - show price range
5742 - if ($product->is_type('variable')) {
5743 - $min_price = $product->get_variation_price('min');
5744 - $max_price = $product->get_variation_price('max');
5745 - if ($min_price !== $max_price) {
5746 - $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5747 - }
5748 - }
5749 -
5750 - if (!empty($sku)) {
5751 - $final_content .= "SKU: " . $sku . "\n";
5752 - }
5753 -
5754 - // Get product categories
5755 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
5756 - if (!empty($categories) && !is_wp_error($categories)) {
5757 - $final_content .= "Categories: " . implode(', ', $categories) . "\n";
5758 - }
5759 - }
5760 - }
5761 -
5762 - // For custom post types like job_listing, include additional fields
5763 - if ($post_type === 'job_listing') {
5764 - // Add job-specific meta if available
5765 - $job_location = get_post_meta($post_id, '_job_location', true);
5766 - if (!empty($job_location)) {
5767 - $final_content .= "\n\nLocation: " . $job_location;
5768 - }
5769 -
5770 - // Get job type terms
5771 - $job_types = get_the_terms($post_id, 'job_listing_type');
5772 - if (!empty($job_types) && !is_wp_error($job_types)) {
5773 - $types = array();
5774 - foreach ($job_types as $type) {
5775 - $types[] = $type->name;
5776 - }
5777 - $final_content .= "\n\nJob Type: " . implode(', ', $types);
5778 - }
5779 -
5780 - // Get company name if available
5781 - $company_name = get_post_meta($post_id, '_company_name', true);
5782 - if (!empty($company_name)) {
5783 - $final_content .= "\n\nCompany: " . $company_name;
5784 - }
5785 - }
5786 -
5787 - // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5788 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5789 - if (!empty($acf_fields)) {
5790 - $acf_content_parts = array();
5791 - $pdf_attachment_ids = array();
5792 -
5793 - foreach ($acf_fields as $field_name => $field_value) {
5794 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5795 - if (!empty($formatted_value)) {
5796 - // Convert field name to readable label
5797 - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5798 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
5799 - }
5800 -
5801 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5802 - }
5803 -
5804 - if (!empty($acf_content_parts)) {
5805 - $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5806 - }
5807 -
5808 - // Gate the auto-sync PDF-extraction loop behind an opt-in option.
5809 - // Mirrors the per-batch checkbox the manual content selector has; the
5810 - // 25 MB size cap lives in the shared extractor so it applies in both
5811 - // paths regardless. Default OFF — re-parsing every ACF PDF on every
5812 - // editor save is expensive and most sites don't want it.
5813 - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
5814 - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
5815 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
5816 - $pdf_sections = array();
5817 - foreach ($pdf_attachment_ids as $att_id) {
5818 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
5819 - if (!empty($pdf_text)) {
5820 - $pdf_title = get_the_title($att_id);
5821 - $pdf_url = wp_get_attachment_url($att_id);
5822 - $header = 'PDF Attachment';
5823 - if (!empty($pdf_title)) {
5824 - $header .= ': ' . $pdf_title;
5825 - }
5826 - if (!empty($pdf_url)) {
5827 - $header .= ' (' . $pdf_url . ')';
5828 - }
5829 - $pdf_sections[] = $header . "\n" . $pdf_text;
5830 - }
5831 - }
5832 - if (!empty($pdf_sections)) {
5833 - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
5834 - }
5835 - }
5836 - }
5837 -
5838 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5839 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
5840 - if (!empty($custom_meta)) {
5841 - $meta_content_parts = array();
5842 -
5843 - foreach ($custom_meta as $meta_key => $meta_value) {
5844 - // Convert meta key to readable label
5845 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
5846 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
5847 - }
5848 -
5849 - if (!empty($meta_content_parts)) {
5850 - $final_content .= "\n\n" . implode("\n", $meta_content_parts);
5851 - }
5852 - }
5853 -
5854 - // Get API key with proper model detection
5855 - $options = get_option('mxchat_options');
5856 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5857 -
5858 - if (strpos($selected_model, 'voyage') === 0) {
5859 - $api_key = $options['voyage_api_key'] ?? '';
5860 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5861 - $api_key = $options['gemini_api_key'] ?? '';
5862 - } else {
5863 - $api_key = $options['api_key'] ?? '';
5864 - }
5865 -
5866 - if (empty($api_key)) {
5867 - return;
5868 - }
5869 -
5870 - // Use the centralized utility function for storage
5871 - $result = MxChat_Utils::submit_content_to_db(
5872 - $final_content,
5873 - $source_url,
5874 - $api_key,
5875 - md5($source_url) // Vector ID for Pinecone
5876 - );
5877 -
5878 - // After successful storage, apply role restriction based on tags
5879 - if (!is_wp_error($result)) {
5880 - $this->apply_role_restriction_to_post($post_id, $source_url);
5881 - }
5882 - }
5883 -
5884 - // Clean up the stored previous status if not used above
5885 - if ($previous_status !== 'publish' || $post->post_status === 'publish') {
5886 - delete_transient($previous_status_key);
5887 - delete_transient($previous_url_key);
5888 - }
5889 -}
5890 -
5891 -/**
5892 - * Store the post status and URL before update to detect status transitions
5893 - * This runs before the post is actually updated in the database
5894 - */
5895 -public function mxchat_store_pre_update_status($post_id, $data) {
5896 - // Get the current post from database (before update)
5897 - $current_post = get_post($post_id);
5898 -
5899 - if ($current_post) {
5900 - // Store the current status temporarily
5901 - $status_key = 'mxchat_prev_status_' . $post_id;
5902 - set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
5903 -
5904 - // If the post is currently published, also store its URL
5905 - if ($current_post->post_status === 'publish') {
5906 - $url_key = 'mxchat_prev_url_' . $post_id;
5907 - $current_url = get_permalink($post_id);
5908 - set_transient($url_key, $current_url, HOUR_IN_SECONDS);
5909 - }
5910 - }
5911 -}
5912 -
5913 -public function mxchat_handle_post_delete($post_id) {
5914 - // Get post data before it's deleted
5915 - $post = get_post($post_id);
5916 -
5917 - // Basic validation
5918 - if (!$post || wp_is_post_revision($post_id)) {
5919 - return;
5920 - }
5921 -
5922 - $post_type = $post->post_type;
5923 -
5924 - // Check if sync is enabled for this post type
5925 - $should_sync = false;
5926 -
5927 - // Check built-in post types first
5928 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5929 - $should_sync = true;
5930 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5931 - $should_sync = true;
5932 - } else {
5933 - // Check custom post types
5934 - $option_name = 'mxchat_auto_sync_' . $post_type;
5935 - if (get_option($option_name) === '1') {
5936 - $should_sync = true;
5937 - }
5938 - }
5939 -
5940 - if (!$should_sync) {
5941 - return;
5942 - }
5943 -
5944 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
5945 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
5946 - // real vector IDs stored under the original URL.
5947 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
5948 - if (!$source_url) {
5949 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5950 - return;
5951 - }
5952 -
5953 - // Use chunk-aware deletion (handles both chunked and non-chunked content)
5954 - $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5955 -
5956 - if (is_wp_error($delete_result)) {
5957 - //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5958 - }
5959 -
5960 - delete_transient('mxchat_prev_url_' . $post_id);
5961 - delete_transient('mxchat_prev_status_' . $post_id);
5962 -}
5963 -
5964 -/**
5965 - * Resolve the source URL for a post being trashed/deleted.
5966 - *
5967 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
5968 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
5969 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
5970 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
5971 - */
5972 -private function mxchat_resolve_pre_trash_url($post_id) {
5973 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
5974 - if (!empty($previous_url)) {
5975 - return $previous_url;
5976 - }
5977 -
5978 - $current = get_permalink($post_id);
5979 - if (!$current) {
5980 - return '';
5981 - }
5982 - return preg_replace('#__trashed(/?)$#', '$1', $current);
5983 -}
5984 -
5985 -
5986 -
5987 -public function mxchat_handle_product_change($post_id, $post, $update) {
5988 - if ($post->post_type !== 'product') {
5989 - return;
5990 - }
5991 -
5992 - if ($post->post_status === 'publish') {
5993 - add_action('shutdown', function() use ($post_id) {
5994 - $product = wc_get_product($post_id);
5995 - if ($product) {
5996 - $this->mxchat_store_product_embedding($product);
5997 - }
5998 - });
5999 - }
6000 -}
6001 -
6002 -/**
6003 - * Store WooCommerce product embeddings
6004 - */
6005 -private function mxchat_store_product_embedding($product) {
6006 - if (!isset($this->options['enable_woocommerce_integration']) ||
6007 - !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6008 - return;
6009 - }
6010 -
6011 - $source_url = get_permalink($product->get_id());
6012 - $product_id = $product->get_id();
6013 -
6014 - // Build product content
6015 - $title = $product->get_name();
6016 - $description = $product->get_description();
6017 - $short_description = $product->get_short_description();
6018 - $regular_price = $product->get_regular_price();
6019 - $sale_price = $product->get_sale_price();
6020 - $price = $product->get_price();
6021 - $sku = $product->get_sku();
6022 -
6023 - // Get currency symbol
6024 - $currency_symbol = get_woocommerce_currency_symbol();
6025 -
6026 - // Format content consistently
6027 - $content = $title . "\n\n";
6028 -
6029 - if (!empty($short_description)) {
6030 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
6031 - }
6032 -
6033 - if (!empty($description)) {
6034 - $content .= wp_strip_all_tags($description) . "\n\n";
6035 - }
6036 -
6037 - // Add pricing information
6038 - if (!empty($regular_price)) {
6039 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
6040 - } elseif (!empty($price)) {
6041 - $content .= "Price: " . $currency_symbol . $price . "\n";
6042 - }
6043 -
6044 - if (!empty($sale_price) && $sale_price !== $regular_price) {
6045 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
6046 - }
6047 -
6048 - // Handle variable products - show price range
6049 - if ($product->is_type('variable')) {
6050 - $min_price = $product->get_variation_price('min');
6051 - $max_price = $product->get_variation_price('max');
6052 - if ($min_price !== $max_price) {
6053 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
6054 - }
6055 - }
6056 -
6057 - if (!empty($sku)) {
6058 - $content .= "SKU: " . $sku . "\n";
6059 - }
6060 -
6061 - // Get product categories
6062 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
6063 - if (!empty($categories) && !is_wp_error($categories)) {
6064 - $content .= "Categories: " . implode(', ', $categories) . "\n";
6065 - }
6066 -
6067 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
6068 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
6069 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
6070 - foreach ($custom_tabs as $tab) {
6071 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6072 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6073 -
6074 - if (!empty($tab_title) && !empty($tab_content)) {
6075 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6076 - }
6077 - }
6078 - }
6079 -
6080 - // Also check for reusable/saved tabs applied to this product
6081 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
6082 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
6083 - // Get the saved tabs option
6084 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
6085 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
6086 - foreach ($applied_saved_tabs as $saved_tab_id) {
6087 - if (isset($saved_tabs[$saved_tab_id])) {
6088 - $tab = $saved_tabs[$saved_tab_id];
6089 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6090 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6091 -
6092 - if (!empty($tab_title) && !empty($tab_content)) {
6093 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6094 - }
6095 - }
6096 - }
6097 - }
6098 - }
6099 -
6100 - // Get API key with proper model detection
6101 - $options = get_option('mxchat_options');
6102 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6103 -
6104 - if (strpos($selected_model, 'voyage') === 0) {
6105 - $api_key = $options['voyage_api_key'] ?? '';
6106 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6107 - $api_key = $options['gemini_api_key'] ?? '';
6108 - } else {
6109 - $api_key = $options['api_key'] ?? '';
6110 - }
6111 -
6112 - if (empty($api_key)) {
6113 - //error_log('MxChat Auto-sync: No API key configured for embedding model');
6114 - return;
6115 - }
6116 -
6117 - // Use the centralized utility function for storage
6118 - $result = MxChat_Utils::submit_content_to_db(
6119 - $content,
6120 - $source_url,
6121 - $api_key,
6122 - md5($source_url) // Vector ID for Pinecone
6123 - );
6124 -
6125 - // After successful storage, apply role restriction based on tags
6126 - if (!is_wp_error($result)) {
6127 - $this->apply_role_restriction_to_post($product_id, $source_url);
6128 - }
6129 -
6130 - if (is_wp_error($result)) {
6131 - //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
6132 - }
6133 -}
6134 -
6135 -public function mxchat_handle_product_delete($post_id) {
6136 - if (get_post_type($post_id) !== 'product') {
6137 - return;
6138 - }
6139 -
6140 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6141 - if (!$source_url) {
6142 - return;
6143 - }
6144 -
6145 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6146 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6147 -
6148 - delete_transient('mxchat_prev_url_' . $post_id);
6149 - delete_transient('mxchat_prev_status_' . $post_id);
6150 -}
6151 -
6152 -/**
6153 - * Handle individual Pinecone content deletion
6154 - */
6155 -public function mxchat_handle_pinecone_prompt_delete() {
6156 - // Check permissions
6157 - if (!current_user_can('manage_options')) {
6158 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
6159 - }
6160 -
6161 - // Verify nonce
6162 - if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
6163 - wp_die(esc_html__('Security check failed.', 'mxchat'));
6164 - }
6165 -
6166 - $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
6167 -
6168 - if (empty($vector_id)) {
6169 - set_transient('mxchat_admin_notice_error',
6170 - esc_html__('Invalid vector ID.', 'mxchat'),
6171 - 30
6172 - );
6173 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6174 - exit;
6175 - }
6176 -
6177 - // Get Pinecone settings
6178 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6179 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6180 -
6181 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6182 - set_transient('mxchat_admin_notice_error',
6183 - esc_html__('Pinecone is not properly configured.', 'mxchat'),
6184 - 30
6185 - );
6186 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6187 - exit;
6188 - }
6189 -
6190 - // Delete from Pinecone
6191 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6192 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
6193 - $vector_id,
6194 - $pinecone_options['mxchat_pinecone_api_key'],
6195 - $pinecone_options['mxchat_pinecone_host']
6196 - );
6197 -
6198 - if ($result['success']) {
6199 - // No cache clearing needed since we removed caching
6200 - set_transient('mxchat_admin_notice_success',
6201 - esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
6202 - 30
6203 - );
6204 - } else {
6205 - set_transient('mxchat_admin_notice_error',
6206 - esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
6207 - 30
6208 - );
6209 - }
6210 -
6211 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6212 - exit;
6213 -}
6214 -/**
6215 - * Handle individual Pinecone content deletion via AJAX
6216 - */
6217 -public function ajax_mxchat_delete_pinecone_prompt() {
6218 - // Verify nonce and permissions
6219 - if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
6220 - wp_send_json_error('Invalid nonce');
6221 - exit;
6222 - }
6223 -
6224 - if (!current_user_can('manage_options')) {
6225 - wp_send_json_error('Unauthorized access');
6226 - exit;
6227 - }
6228 -
6229 - $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
6230 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6231 -
6232 - if (empty($vector_id)) {
6233 - wp_send_json_error('Missing vector ID');
6234 - exit;
6235 - }
6236 -
6237 - // Get bot-specific Pinecone settings
6238 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6239 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6240 -
6241 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6242 -
6243 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6244 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
6245 - exit;
6246 - }
6247 -
6248 - // Delete from the correct Pinecone index
6249 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
6250 - $vector_id,
6251 - $pinecone_options['mxchat_pinecone_api_key'],
6252 - $pinecone_options['mxchat_pinecone_host']
6253 - );
6254 -
6255 - if ($result['success']) {
6256 - // No cache clearing needed since we removed caching
6257 - wp_send_json_success(array(
6258 - 'message' => 'Entry deleted successfully from Pinecone',
6259 - 'vector_id' => $vector_id,
6260 - 'bot_id' => $bot_id
6261 - ));
6262 - } else {
6263 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
6264 - wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
6265 - }
6266 -
6267 - exit;
6268 -}
6269 -
6270 -/**
6271 - * Handle deletion of all chunks for a given source URL via AJAX
6272 - * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
6273 - */
6274 -public function ajax_mxchat_delete_chunks_by_url() {
6275 - // Verify nonce and permissions
6276 - if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
6277 - wp_send_json_error('Invalid nonce');
6278 - exit;
6279 - }
6280 -
6281 - if (!current_user_can('manage_options')) {
6282 - wp_send_json_error('Unauthorized access');
6283 - exit;
6284 - }
6285 -
6286 - $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
6287 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
6288 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6289 -
6290 - if (empty($source_url)) {
6291 - wp_send_json_error('Missing source URL');
6292 - exit;
6293 - }
6294 -
6295 - // Generate the base vector ID from the source URL (same as how chunks are created)
6296 - $base_vector_id = md5($source_url);
6297 -
6298 - if ($data_source === 'pinecone') {
6299 - // Get bot-specific Pinecone settings (same as working delete function)
6300 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6301 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6302 -
6303 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6304 -
6305 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6306 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
6307 - exit;
6308 - }
6309 -
6310 - $api_key = $pinecone_options['mxchat_pinecone_api_key'];
6311 - $host = $pinecone_options['mxchat_pinecone_host'];
6312 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
6313 -
6314 - // Collect all vector IDs to delete
6315 - $vectors_to_delete = array();
6316 -
6317 - // Add the original single-vector ID (for non-chunked content)
6318 - $vectors_to_delete[] = $base_vector_id;
6319 -
6320 - // Use Pinecone list API to find all chunk vectors with this prefix
6321 - // NOTE: Pinecone List API is a GET request with query parameters, not POST
6322 - $prefix = $base_vector_id . '_chunk_';
6323 -
6324 - $query_params = array(
6325 - 'prefix' => $prefix,
6326 - 'limit' => 100
6327 - );
6328 -
6329 - if (!empty($namespace)) {
6330 - $query_params['namespace'] = $namespace;
6331 - }
6332 -
6333 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
6334 -
6335 - $list_response = wp_remote_get($list_url, array(
6336 - 'headers' => array(
6337 - 'Api-Key' => $api_key,
6338 - 'accept' => 'application/json'
6339 - ),
6340 - 'timeout' => 30
6341 - ));
6342 -
6343 - if (!is_wp_error($list_response)) {
6344 - $list_body_response = wp_remote_retrieve_body($list_response);
6345 - $list_data = json_decode($list_body_response, true);
6346 - if (!empty($list_data['vectors'])) {
6347 - foreach ($list_data['vectors'] as $vector) {
6348 - if (isset($vector['id'])) {
6349 - $vectors_to_delete[] = $vector['id'];
6350 - }
6351 - }
6352 - }
6353 - }
6354 -
6355 - if (empty($vectors_to_delete)) {
6356 - wp_send_json_success(array(
6357 - 'message' => 'No vectors found to delete',
6358 - 'source_url' => $source_url
6359 - ));
6360 - exit;
6361 - }
6362 -
6363 - // Delete all vectors using the same endpoint as the working function
6364 - $delete_url = "https://{$host}/vectors/delete";
6365 -
6366 - $delete_body = array(
6367 - 'ids' => $vectors_to_delete
6368 - );
6369 -
6370 - if (!empty($namespace)) {
6371 - $delete_body['namespace'] = $namespace;
6372 - }
6373 -
6374 - $delete_response = wp_remote_post($delete_url, array(
6375 - 'headers' => array(
6376 - 'Api-Key' => $api_key,
6377 - 'accept' => 'application/json',
6378 - 'content-type' => 'application/json'
6379 - ),
6380 - 'body' => wp_json_encode($delete_body),
6381 - 'timeout' => 30
6382 - ));
6383 -
6384 - if (is_wp_error($delete_response)) {
6385 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
6386 - wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
6387 - exit;
6388 - }
6389 -
6390 - $response_code = wp_remote_retrieve_response_code($delete_response);
6391 -
6392 - if ($response_code !== 200) {
6393 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
6394 - wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
6395 - exit;
6396 - }
6397 -
6398 - wp_send_json_success(array(
6399 - 'message' => 'All chunks deleted successfully from Pinecone',
6400 - 'source_url' => $source_url,
6401 - 'deleted_count' => count($vectors_to_delete)
6402 - ));
6403 -
6404 - } else {
6405 - // WordPress database deletion
6406 - global $wpdb;
6407 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6408 -
6409 - $result = $wpdb->delete(
6410 - $table_name,
6411 - array('source_url' => $source_url),
6412 - array('%s')
6413 - );
6414 -
6415 - if ($result === false) {
6416 - MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
6417 - wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
6418 - exit;
6419 - }
6420 -
6421 - wp_send_json_success(array(
6422 - 'message' => 'All chunks deleted successfully from database',
6423 - 'source_url' => $source_url,
6424 - 'deleted_count' => $result
6425 - ));
6426 - }
6427 -
6428 - exit;
6429 -}
6430 -
6431 -/**
6432 - * Handle individual WordPress database content deletion via AJAX
6433 - * Mirrors the Pinecone delete handler but for WordPress database entries
6434 - */
6435 -public function ajax_mxchat_delete_wordpress_prompt() {
6436 - // Verify nonce and permissions
6437 - if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
6438 - wp_send_json_error('Invalid nonce');
6439 - exit;
6440 - }
6441 -
6442 - if (!current_user_can('manage_options')) {
6443 - wp_send_json_error('Unauthorized access');
6444 - exit;
6445 - }
6446 -
6447 - $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
6448 -
6449 - if (empty($entry_id)) {
6450 - wp_send_json_error('Missing entry ID');
6451 - exit;
6452 - }
6453 -
6454 - global $wpdb;
6455 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6456 -
6457 - // Clear cache for this entry
6458 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
6459 -
6460 - // Delete from database
6461 - $result = $wpdb->delete(
6462 - $table_name,
6463 - array('id' => $entry_id),
6464 - array('%d')
6465 - );
6466 -
6467 - if ($result !== false) {
6468 - wp_send_json_success(array(
6469 - 'message' => 'Entry deleted successfully',
6470 - 'entry_id' => $entry_id
6471 - ));
6472 - } else {
6473 - wp_send_json_error('Failed to delete entry from database');
6474 - }
6475 -
6476 - exit;
6477 -}
6478 -
6479 -/**
6480 - * Handle bulk deletion of knowledge entries via AJAX
6481 - * Supports both Pinecone and WordPress database entries
6482 - */
6483 -public function ajax_mxchat_bulk_delete_knowledge() {
6484 - // Verify nonce and permissions
6485 - if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
6486 - wp_send_json_error('Invalid nonce');
6487 - exit;
6488 - }
6489 -
6490 - if (!current_user_can('manage_options')) {
6491 - wp_send_json_error('Unauthorized access');
6492 - exit;
6493 - }
6494 -
6495 - $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
6496 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6497 -
6498 - if (empty($entries) || !is_array($entries)) {
6499 - wp_send_json_error('No entries provided');
6500 - exit;
6501 - }
6502 -
6503 - // Extend execution time — bulk Pinecone operations can take a while
6504 - if (function_exists('set_time_limit')) {
6505 - set_time_limit(120);
6506 - }
6507 -
6508 - $success_ids = array();
6509 - $failed_ids = array();
6510 - $errors = array();
6511 -
6512 - global $wpdb;
6513 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6514 -
6515 - // Get Pinecone manager for Pinecone deletions
6516 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6517 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6518 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6519 -
6520 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
6521 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
6522 -
6523 - // =============================================
6524 - // PHASE 1: Collect all Pinecone vector IDs
6525 - // and separate WordPress entries
6526 - // =============================================
6527 - $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
6528 - $wordpress_entries = array(); // entries for WordPress DB deletion
6529 - $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
6530 -
6531 - foreach ($entries as $entry) {
6532 - $entry_id = sanitize_text_field($entry['id'] ?? '');
6533 - $source = sanitize_text_field($entry['source'] ?? 'wordpress');
6534 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
6535 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
6536 -
6537 - if (empty($entry_id)) {
6538 - continue;
6539 - }
6540 -
6541 - if ($source === 'pinecone') {
6542 - if (!$use_pinecone || empty($api_key)) {
6543 - $failed_ids[] = $entry_id;
6544 - $errors[] = "Pinecone not configured for entry: $entry_id";
6545 - continue;
6546 - }
6547 -
6548 - $pinecone_entry_ids[] = $entry_id;
6549 -
6550 - if ($is_group && !empty($source_url)) {
6551 - // Grouped/chunked entry: collect base ID + chunk IDs via List API
6552 - $base_vector_id = md5($source_url);
6553 - $all_vector_ids[] = $base_vector_id;
6554 -
6555 - $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
6556 - $list_response = wp_remote_get($list_url, array(
6557 - 'headers' => array(
6558 - 'Api-Key' => $api_key,
6559 - 'accept' => 'application/json'
6560 - ),
6561 - 'timeout' => 30
6562 - ));
6563 -
6564 - if (!is_wp_error($list_response)) {
6565 - $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
6566 - if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
6567 - foreach ($list_body['vectors'] as $vector) {
6568 - if (isset($vector['id'])) {
6569 - $all_vector_ids[] = $vector['id'];
6570 - }
6571 - }
6572 - }
6573 - }
6574 - } else {
6575 - // Single entry: the entry_id IS the vector ID
6576 - $all_vector_ids[] = $entry_id;
6577 - }
6578 - } else {
6579 - $wordpress_entries[] = $entry;
6580 - }
6581 - }
6582 -
6583 - // =============================================
6584 - // PHASE 2: Single batch delete to Pinecone
6585 - // =============================================
6586 - if (!empty($all_vector_ids)) {
6587 - $all_vector_ids = array_values(array_unique($all_vector_ids));
6588 - $pinecone_success = true;
6589 - $batches = array_chunk($all_vector_ids, 100);
6590 -
6591 - foreach ($batches as $batch) {
6592 - $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
6593 - 'headers' => array(
6594 - 'Api-Key' => $api_key,
6595 - 'accept' => 'application/json',
6596 - 'content-type' => 'application/json'
6597 - ),
6598 - 'body' => wp_json_encode(array('ids' => $batch)),
6599 - 'timeout' => 60
6600 - ));
6601 -
6602 - if (is_wp_error($delete_response)) {
6603 - $pinecone_success = false;
6604 - $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
6605 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
6606 - } else {
6607 - $response_code = wp_remote_retrieve_response_code($delete_response);
6608 - if ($response_code !== 200) {
6609 - $pinecone_success = false;
6610 - $response_body = wp_remote_retrieve_body($delete_response);
6611 - $errors[] = "Pinecone API error (HTTP $response_code)";
6612 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
6613 - }
6614 - }
6615 - }
6616 -
6617 - // Mark all pinecone entries based on batch result
6618 - foreach ($pinecone_entry_ids as $eid) {
6619 - if ($pinecone_success) {
6620 - $success_ids[] = $eid;
6621 - } else {
6622 - $failed_ids[] = $eid;
6623 - }
6624 - }
6625 - }
6626 -
6627 - // =============================================
6628 - // PHASE 3: WordPress database deletions
6629 - // =============================================
6630 - foreach ($wordpress_entries as $entry) {
6631 - $entry_id = sanitize_text_field($entry['id'] ?? '');
6632 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
6633 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
6634 -
6635 - if (empty($entry_id)) {
6636 - continue;
6637 - }
6638 -
6639 - try {
6640 - if ($is_group && !empty($source_url)) {
6641 - $result = $wpdb->delete(
6642 - $table_name,
6643 - array('source_url' => $source_url),
6644 - array('%s')
6645 - );
6646 - } else {
6647 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
6648 - $result = $wpdb->delete(
6649 - $table_name,
6650 - array('id' => intval($entry_id)),
6651 - array('%d')
6652 - );
6653 - }
6654 -
6655 - if ($result !== false) {
6656 - $success_ids[] = $entry_id;
6657 - } else {
6658 - $failed_ids[] = $entry_id;
6659 - $errors[] = "Database error for entry: $entry_id";
6660 - }
6661 - } catch (Exception $e) {
6662 - $failed_ids[] = $entry_id;
6663 - $errors[] = $e->getMessage();
6664 - }
6665 - }
6666 -
6667 - wp_send_json_success(array(
6668 - 'success_ids' => $success_ids,
6669 - 'failed_ids' => $failed_ids,
6670 - 'errors' => $errors,
6671 - 'total_processed' => count($success_ids) + count($failed_ids)
6672 - ));
6673 -
6674 - exit;
6675 -}
6676 -
6677 -/**
6678 - * Get hierarchical roles for dropdown
6679 - */
6680 -public function mxchat_get_role_options() {
6681 - return array(
6682 - 'public' => __('Public (Everyone)', 'mxchat'),
6683 - 'logged_in' => __('Logged In Users', 'mxchat'),
6684 - 'subscriber' => __('Subscribers & Above', 'mxchat'),
6685 - 'contributor' => __('Contributors & Above', 'mxchat'),
6686 - 'author' => __('Authors & Above', 'mxchat'),
6687 - 'editor' => __('Editors & Above', 'mxchat'),
6688 - 'administrator' => __('Administrators Only', 'mxchat')
6689 - );
6690 -}
6691 -
6692 -/**
6693 - * Check if user has access to content based on role restriction
6694 - */
6695 -public function mxchat_user_has_content_access($role_restriction) {
6696 - // Public content is always accessible
6697 - if ($role_restriction === 'public' || empty($role_restriction)) {
6698 - return true;
6699 - }
6700 -
6701 - // Check if user is logged in for logged_in restriction
6702 - if ($role_restriction === 'logged_in') {
6703 - return is_user_logged_in();
6704 - }
6705 -
6706 - // If not logged in, no access to role-restricted content
6707 - if (!is_user_logged_in()) {
6708 - return false;
6709 - }
6710 -
6711 - $user = wp_get_current_user();
6712 - $user_roles = $user->roles;
6713 -
6714 - if (empty($user_roles)) {
6715 - return false;
6716 - }
6717 -
6718 - // Define role hierarchy (higher number = higher access)
6719 - $hierarchy = array(
6720 - 'subscriber' => 1,
6721 - 'contributor' => 2,
6722 - 'author' => 3,
6723 - 'editor' => 4,
6724 - 'administrator' => 5
6725 - );
6726 -
6727 - // Get required level
6728 - $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
6729 -
6730 - // Check if user has required level or higher
6731 - foreach ($user_roles as $user_role) {
6732 - $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
6733 - if ($user_level >= $required_level) {
6734 - return true;
6735 - }
6736 - }
6737 -
6738 - return false;
6739 -}
6740 -
6741 -/**
6742 - * Handle role restriction updates via AJAX
6743 - * Removed cache clearing call since we removed caching
6744 - */
6745 -public function ajax_mxchat_update_role_restriction() {
6746 - // Verify nonce and permissions
6747 - if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
6748 - wp_send_json_error('Invalid nonce');
6749 - exit;
6750 - }
6751 -
6752 - if (!current_user_can('manage_options')) {
6753 - wp_send_json_error('Unauthorized access');
6754 - exit;
6755 - }
6756 -
6757 - $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
6758 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6759 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
6760 -
6761 - if (empty($entry_id)) {
6762 - wp_send_json_error('Invalid entry ID');
6763 - exit;
6764 - }
6765 -
6766 - // Get knowledge manager instance to validate role restriction
6767 - $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6768 - $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
6769 - if (!in_array($role_restriction, $valid_roles)) {
6770 - wp_send_json_error('Invalid role restriction');
6771 - exit;
6772 - }
6773 -
6774 - global $wpdb;
6775 -
6776 - if ($data_source === 'pinecone') {
6777 - // Handle Pinecone role restriction (stored separately in WordPress table)
6778 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6779 -
6780 - // Use REPLACE to insert or update the role restriction
6781 - $result = $wpdb->replace(
6782 - $roles_table,
6783 - array(
6784 - 'vector_id' => $entry_id,
6785 - 'role_restriction' => $role_restriction,
6786 - 'updated_at' => current_time('mysql')
6787 - ),
6788 - array('%s', '%s', '%s')
6789 - );
6790 -
6791 - // No cache clearing needed since we removed caching
6792 -
6793 - } else {
6794 - // Handle WordPress database role restriction (existing functionality)
6795 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6796 -
6797 - $result = $wpdb->update(
6798 - $table_name,
6799 - array('role_restriction' => $role_restriction),
6800 - array('id' => absint($entry_id)),
6801 - array('%s'),
6802 - array('%d')
6803 - );
6804 - }
6805 -
6806 - if ($result === false) {
6807 - wp_send_json_error('Database update failed: ' . $wpdb->last_error);
6808 - exit;
6809 - }
6810 -
6811 - wp_send_json_success(array(
6812 - 'message' => 'Role restriction updated successfully',
6813 - 'role_restriction' => $role_restriction,
6814 - 'data_source' => $data_source,
6815 - 'entry_id' => $entry_id
6816 - ));
6817 - exit;
6818 -}
6819 -
6820 -// ========================================
6821 -// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
6822 -// Add these to your MxChat_Knowledge_Manager class
6823 -// ========================================
6824 -
6825 -/**
6826 - * Initialize role-based content hooks
6827 - * Add this call to your __construct() or mxchat_init_hooks() method
6828 - */
6829 -private function mxchat_init_role_hooks() {
6830 - // AJAX handlers for tag-role mappings
6831 - add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
6832 - add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
6833 - add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
6834 - add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
6835 -
6836 - // Hook to automatically update role restrictions when tags are added/removed
6837 - add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
6838 -
6839 - // Hook to apply role restrictions on auto-sync
6840 - add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
6841 -}
6842 -
6843 -/**
6844 - * Add tag-role mapping via AJAX
6845 - */
6846 -public function ajax_add_tag_role_mapping() {
6847 - // Verify nonce and permissions
6848 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6849 -
6850 - if (!current_user_can('manage_options')) {
6851 - wp_send_json_error('Unauthorized access');
6852 - exit;
6853 - }
6854 -
6855 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6856 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6857 -
6858 - if (empty($tag_input)) {
6859 - wp_send_json_error('Please enter a tag name or slug');
6860 - exit;
6861 - }
6862 -
6863 - // Validate role restriction
6864 - $valid_roles = array_keys($this->mxchat_get_role_options());
6865 - if (!in_array($role_restriction, $valid_roles)) {
6866 - wp_send_json_error('Invalid role restriction');
6867 - exit;
6868 - }
6869 -
6870 - // Resolve the tag by slug first, then fall back to its display name, so users can
6871 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
6872 - // labeled by name but previously validated by slug only, producing the confusing
6873 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
6874 - $term = get_term_by('slug', $tag_input, 'post_tag');
6875 - if (!$term) {
6876 - $term = get_term_by('name', $tag_input, 'post_tag');
6877 - }
6878 - if (!$term) {
6879 - 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.');
6880 - exit;
6881 - }
6882 -
6883 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
6884 - // compares against each post's tag slugs, so the stored key must be a slug,
6885 - // never the raw (possibly display-name) input.
6886 - $tag_slug = $term->slug;
6887 -
6888 - // Get existing mappings
6889 - $mappings = get_option('mxchat_tag_role_mappings', array());
6890 -
6891 - // Check if mapping already exists
6892 - if (isset($mappings[$tag_slug])) {
6893 - wp_send_json_error('Mapping for this tag already exists');
6894 - exit;
6895 - }
6896 -
6897 - // Add new mapping
6898 - $mappings[$tag_slug] = $role_restriction;
6899 - update_option('mxchat_tag_role_mappings', $mappings);
6900 -
6901 - wp_send_json_success(array(
6902 - 'message' => 'Tag-role mapping added successfully',
6903 - 'tag_slug' => $tag_slug,
6904 - 'role_restriction' => $role_restriction
6905 - ));
6906 - exit;
6907 -}
6908 -
6909 -/**
6910 - * Delete tag-role mapping via AJAX
6911 - */
6912 -public function ajax_delete_tag_role_mapping() {
6913 - // Verify nonce and permissions
6914 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6915 -
6916 - if (!current_user_can('manage_options')) {
6917 - wp_send_json_error('Unauthorized access');
6918 - exit;
6919 - }
6920 -
6921 - $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6922 -
6923 - if (empty($tag_slug)) {
6924 - wp_send_json_error('Tag slug is required');
6925 - exit;
6926 - }
6927 -
6928 - // Get existing mappings
6929 - $mappings = get_option('mxchat_tag_role_mappings', array());
6930 -
6931 - // Check if mapping exists
6932 - if (!isset($mappings[$tag_slug])) {
6933 - wp_send_json_error('Mapping does not exist');
6934 - exit;
6935 - }
6936 -
6937 - // Remove mapping
6938 - unset($mappings[$tag_slug]);
6939 - update_option('mxchat_tag_role_mappings', $mappings);
6940 -
6941 - wp_send_json_success(array(
6942 - 'message' => 'Tag-role mapping deleted successfully',
6943 - 'tag_slug' => $tag_slug
6944 - ));
6945 - exit;
6946 -}
6947 -
6948 -/**
6949 - * Get all tag-role mappings via AJAX
6950 - */
6951 -public function ajax_get_tag_role_mappings() {
6952 - // Verify nonce and permissions
6953 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6954 -
6955 - if (!current_user_can('manage_options')) {
6956 - wp_send_json_error('Unauthorized access');
6957 - exit;
6958 - }
6959 -
6960 - // Get mappings
6961 - $mappings = get_option('mxchat_tag_role_mappings', array());
6962 - $role_options = $this->mxchat_get_role_options();
6963 -
6964 - $formatted_mappings = array();
6965 -
6966 - foreach ($mappings as $tag_slug => $role_restriction) {
6967 - // Get tag object
6968 - $term = get_term_by('slug', $tag_slug, 'post_tag');
6969 -
6970 - // Count posts with this tag
6971 - $post_count = 0;
6972 - if ($term) {
6973 - $post_count = $term->count;
6974 - }
6975 -
6976 - $formatted_mappings[] = array(
6977 - 'tag_slug' => $tag_slug,
6978 - 'role_restriction' => $role_restriction,
6979 - 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
6980 - 'post_count' => $post_count
6981 - );
6982 - }
6983 -
6984 - wp_send_json_success(array(
6985 - 'mappings' => $formatted_mappings
6986 - ));
6987 - exit;
6988 -}
6989 -
6990 -/**
6991 - * Bulk update role restrictions for all existing content with mapped tags
6992 - */
6993 -public function ajax_bulk_update_tag_roles() {
6994 - // Verify nonce and permissions
6995 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6996 -
6997 - if (!current_user_can('manage_options')) {
6998 - wp_send_json_error('Unauthorized access');
6999 - exit;
7000 - }
7001 -
7002 - // Get mappings
7003 - $mappings = get_option('mxchat_tag_role_mappings', array());
7004 -
7005 - if (empty($mappings)) {
7006 - wp_send_json_error('No tag-role mappings found');
7007 - exit;
7008 - }
7009 -
7010 - global $wpdb;
7011 -
7012 - // Check if using Pinecone
7013 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7014 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7015 -
7016 - $updated_count = 0;
7017 - $details = array();
7018 -
7019 - foreach ($mappings as $tag_slug => $role_restriction) {
7020 - // Get all posts with this tag
7021 - $posts = get_posts(array(
7022 - 'tag' => $tag_slug,
7023 - 'post_type' => 'any',
7024 - 'posts_per_page' => -1,
7025 - 'fields' => 'ids',
7026 - 'post_status' => 'publish'
7027 - ));
7028 -
7029 - if (empty($posts)) {
7030 - continue;
7031 - }
7032 -
7033 - $tag_updated = 0;
7034 -
7035 - foreach ($posts as $post_id) {
7036 - $source_url = get_permalink($post_id);
7037 - if (!$source_url) {
7038 - continue;
7039 - }
7040 -
7041 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7042 - // Update Pinecone role restriction
7043 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7044 - $vector_id = md5($source_url);
7045 -
7046 - $result = $wpdb->replace(
7047 - $roles_table,
7048 - array(
7049 - 'vector_id' => $vector_id,
7050 - 'role_restriction' => $role_restriction,
7051 - 'updated_at' => current_time('mysql')
7052 - ),
7053 - array('%s', '%s', '%s')
7054 - );
7055 - } else {
7056 - // Update WordPress DB
7057 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7058 -
7059 - $result = $wpdb->update(
7060 - $table_name,
7061 - array('role_restriction' => $role_restriction),
7062 - array('source_url' => $source_url),
7063 - array('%s'),
7064 - array('%s')
7065 - );
7066 - }
7067 -
7068 - if ($result !== false) {
7069 - $tag_updated++;
7070 - $updated_count++;
7071 - }
7072 - }
7073 -
7074 - if ($tag_updated > 0) {
7075 - $details[] = sprintf(
7076 - 'Tag "%s" (%s): %d posts updated',
7077 - $tag_slug,
7078 - $role_restriction,
7079 - $tag_updated
7080 - );
7081 - }
7082 - }
7083 -
7084 - wp_send_json_success(array(
7085 - 'message' => 'Bulk update completed',
7086 - 'updated_count' => $updated_count,
7087 - 'tags_processed' => count($mappings),
7088 - 'details' => $details
7089 - ));
7090 - exit;
7091 -}
7092 -
7093 -/**
7094 - * Handle tag changes on posts (when tags are added or removed)
7095 - */
7096 -public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
7097 - // Only process post tags
7098 - if ($taxonomy !== 'post_tag') {
7099 - return;
7100 - }
7101 -
7102 - // Get tag-role mappings
7103 - $mappings = get_option('mxchat_tag_role_mappings', array());
7104 -
7105 - if (empty($mappings)) {
7106 - return;
7107 - }
7108 -
7109 - // Get the post's URL
7110 - $source_url = get_permalink($object_id);
7111 - if (!$source_url) {
7112 - return;
7113 - }
7114 -
7115 - // Determine the highest role restriction based on tags
7116 - $highest_role = 'public';
7117 - $role_hierarchy = array(
7118 - 'public' => 0,
7119 - 'logged_in' => 1,
7120 - 'subscriber' => 2,
7121 - 'contributor' => 3,
7122 - 'author' => 4,
7123 - 'editor' => 5,
7124 - 'administrator' => 6
7125 - );
7126 -
7127 - // Get all current tags for the post
7128 - $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
7129 -
7130 - // Find the highest role restriction among the tags
7131 - foreach ($current_tags as $tag_slug) {
7132 - if (isset($mappings[$tag_slug])) {
7133 - $role = $mappings[$tag_slug];
7134 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
7135 - $highest_role = $role;
7136 - }
7137 - }
7138 - }
7139 -
7140 - // Update the role restriction in the database
7141 - global $wpdb;
7142 -
7143 - // Check if using Pinecone
7144 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7145 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7146 -
7147 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7148 - // Update Pinecone role restriction
7149 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7150 - $vector_id = md5($source_url);
7151 -
7152 - $wpdb->replace(
7153 - $roles_table,
7154 - array(
7155 - 'vector_id' => $vector_id,
7156 - 'role_restriction' => $highest_role,
7157 - 'updated_at' => current_time('mysql')
7158 - ),
7159 - array('%s', '%s', '%s')
7160 - );
7161 - } else {
7162 - // Update WordPress DB
7163 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7164 -
7165 - $wpdb->update(
7166 - $table_name,
7167 - array('role_restriction' => $highest_role),
7168 - array('source_url' => $source_url),
7169 - array('%s'),
7170 - array('%s')
7171 - );
7172 - }
7173 -}
7174 -
7175 -/**
7176 - * Apply role restriction after content is stored (for auto-sync)
7177 - */
7178 -public function apply_role_restriction_after_storage($post_id, $source_url) {
7179 - // Get tag-role mappings
7180 - $mappings = get_option('mxchat_tag_role_mappings', array());
7181 -
7182 - if (empty($mappings)) {
7183 - return;
7184 - }
7185 -
7186 - // Get all tags for the post
7187 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
7188 -
7189 - if (empty($post_tags)) {
7190 - return;
7191 - }
7192 -
7193 - // Determine the highest role restriction based on tags
7194 - $highest_role = 'public';
7195 - $role_hierarchy = array(
7196 - 'public' => 0,
7197 - 'logged_in' => 1,
7198 - 'subscriber' => 2,
7199 - 'contributor' => 3,
7200 - 'author' => 4,
7201 - 'editor' => 5,
7202 - 'administrator' => 6
7203 - );
7204 -
7205 - foreach ($post_tags as $tag_slug) {
7206 - if (isset($mappings[$tag_slug])) {
7207 - $role = $mappings[$tag_slug];
7208 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
7209 - $highest_role = $role;
7210 - }
7211 - }
7212 - }
7213 -
7214 - // If no restricted tags found, return (leave as public)
7215 - if ($highest_role === 'public') {
7216 - return;
7217 - }
7218 -
7219 - // Update the role restriction
7220 - global $wpdb;
7221 -
7222 - // Check if using Pinecone
7223 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7224 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7225 -
7226 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7227 - // Update Pinecone role restriction
7228 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7229 - $vector_id = md5($source_url);
7230 -
7231 - $wpdb->replace(
7232 - $roles_table,
7233 - array(
7234 - 'vector_id' => $vector_id,
7235 - 'role_restriction' => $highest_role,
7236 - 'updated_at' => current_time('mysql')
7237 - ),
7238 - array('%s', '%s', '%s')
7239 - );
7240 - } else {
7241 - // Update WordPress DB
7242 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7243 -
7244 - $wpdb->update(
7245 - $table_name,
7246 - array('role_restriction' => $highest_role),
7247 - array('source_url' => $source_url),
7248 - array('%s'),
7249 - array('%s')
7250 - );
7251 - }
7252 -}
7253 -
7254 -
7255 - // ========================================
7256 - // HELPER METHODS
7257 - // ========================================
7258 -
7259 - /**
7260 - * Check if user has required permissions for content processing
7261 - */
7262 - private function mxchat_check_user_permissions() {
7263 - if (!current_user_can('manage_options')) {
7264 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
7265 - }
7266 - }
7267 -
7268 - /**
7269 - * Validate nonce for security
7270 - */
7271 - private function mxchat_validate_nonce($nonce_name, $nonce_action) {
7272 - if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
7273 - wp_die(esc_html__('Security check failed.', 'mxchat'));
7274 - }
7275 - }
7276 -
7277 - /**
7278 - * Get embedding API credentials
7279 - */
7280 - private function mxchat_get_embedding_credentials() {
7281 - $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
7282 -
7283 - if (strpos($embedding_model, 'text-embedding-') !== false) {
7284 - return array(
7285 - 'type' => 'openai',
7286 - 'api_key' => $this->options['api_key'] ?? ''
7287 - );
7288 - } elseif (strpos($embedding_model, 'voyage-') !== false) {
7289 - return array(
7290 - 'type' => 'voyage',
7291 - 'api_key' => $this->options['voyage_api_key'] ?? ''
7292 - );
7293 - } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
7294 - return array(
7295 - 'type' => 'gemini',
7296 - 'api_key' => $this->options['gemini_api_key'] ?? ''
7297 - );
7298 - }
7299 -
7300 - return array('type' => 'unknown', 'api_key' => '');
7301 - }
7302 -
7303 - /**
7304 - * Log processing errors
7305 - */
7306 - private function mxchat_log_processing_error($operation, $error_message) {
7307 - //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
7308 - }
7309 -
7310 - /**
7311 - * Set admin notice transient
7312 - */
7313 - private function mxchat_set_admin_notice($type, $message) {
7314 - set_transient("mxchat_admin_notice_{$type}", $message, 30);
7315 - }
7316 -
7317 - /**
7318 - * Get Pinecone manager instance for vector operations
7319 - */
7320 - private function mxchat_get_pinecone_manager() {
7321 - return MxChat_Pinecone_Manager::get_instance();
7322 - }
7323 -
7324 -
7325 - // ========================================
7326 -// DATABASE QUEUE TABLE MANAGEMENT
7327 -// ========================================
7328 -
7329 -/**
7330 - * Create queue table on plugin activation
7331 - * Call this from your plugin activation hook
7332 - */
7333 -public function mxchat_create_queue_table() {
7334 - global $wpdb;
7335 -
7336 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7337 - $charset_collate = $wpdb->get_charset_collate();
7338 -
7339 - $sql = "CREATE TABLE IF NOT EXISTS $table_name (
7340 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
7341 - queue_id varchar(64) NOT NULL,
7342 - item_type varchar(20) NOT NULL,
7343 - item_data longtext NOT NULL,
7344 - status varchar(20) NOT NULL DEFAULT 'pending',
7345 - bot_id varchar(50) NOT NULL DEFAULT 'default',
7346 - priority int(11) NOT NULL DEFAULT 0,
7347 - attempts int(11) NOT NULL DEFAULT 0,
7348 - max_attempts int(11) NOT NULL DEFAULT 3,
7349 - error_message text DEFAULT NULL,
7350 - created_at datetime NOT NULL,
7351 - started_at datetime DEFAULT NULL,
7352 - completed_at datetime DEFAULT NULL,
7353 - PRIMARY KEY (id),
7354 - KEY queue_id (queue_id),
7355 - KEY status (status),
7356 - KEY item_type (item_type),
7357 - KEY priority (priority)
7358 - ) $charset_collate;";
7359 -
7360 - require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
7361 - dbDelta($sql);
7362 -
7363 - // Also create a meta table for queue metadata
7364 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7365 -
7366 - $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
7367 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
7368 - queue_id varchar(64) NOT NULL,
7369 - meta_key varchar(255) NOT NULL,
7370 - meta_value longtext,
7371 - PRIMARY KEY (id),
7372 - KEY queue_id (queue_id),
7373 - KEY meta_key (meta_key)
7374 - ) $charset_collate;";
7375 -
7376 - dbDelta($meta_sql);
7377 -}
7378 -
7379 -/**
7380 - * Add items to the processing queue
7381 - *
7382 - * @param string $queue_id Unique identifier for this queue batch
7383 - * @param string $item_type Type of item (url, pdf_page)
7384 - * @param array $items Array of items to queue
7385 - * @param string $bot_id Bot ID for processing
7386 - * @return int Number of items queued
7387 - */
7388 -private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
7389 - global $wpdb;
7390 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7391 -
7392 - $queued_count = 0;
7393 - $priority = 0;
7394 -
7395 - foreach ($items as $item) {
7396 - $result = $wpdb->insert(
7397 - $table_name,
7398 - array(
7399 - 'queue_id' => $queue_id,
7400 - 'item_type' => $item_type,
7401 - 'item_data' => wp_json_encode($item),
7402 - 'status' => 'pending',
7403 - 'bot_id' => $bot_id,
7404 - 'priority' => $priority,
7405 - 'attempts' => 0,
7406 - 'max_attempts' => 3,
7407 - 'created_at' => current_time('mysql')
7408 - ),
7409 - array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
7410 - );
7411 -
7412 - if ($result) {
7413 - $queued_count++;
7414 - }
7415 -
7416 - $priority++; // Process in order
7417 - }
7418 -
7419 - return $queued_count;
7420 -}
7421 -
7422 -/**
7423 - * Store queue metadata (total counts, source URL, etc.)
7424 - */
7425 -private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
7426 - global $wpdb;
7427 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7428 -
7429 - // Check if meta exists
7430 - $existing = $wpdb->get_var($wpdb->prepare(
7431 - "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
7432 - $queue_id,
7433 - $meta_key
7434 - ));
7435 -
7436 - if ($existing) {
7437 - // Update
7438 - $wpdb->update(
7439 - $meta_table,
7440 - array('meta_value' => maybe_serialize($meta_value)),
7441 - array('queue_id' => $queue_id, 'meta_key' => $meta_key),
7442 - array('%s'),
7443 - array('%s', '%s')
7444 - );
7445 - } else {
7446 - // Insert
7447 - $wpdb->insert(
7448 - $meta_table,
7449 - array(
7450 - 'queue_id' => $queue_id,
7451 - 'meta_key' => $meta_key,
7452 - 'meta_value' => maybe_serialize($meta_value)
7453 - ),
7454 - array('%s', '%s', '%s')
7455 - );
7456 - }
7457 -}
7458 -
7459 -/**
7460 - * Get queue metadata
7461 - */
7462 -private function mxchat_get_queue_meta($queue_id, $meta_key) {
7463 - global $wpdb;
7464 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7465 -
7466 - $value = $wpdb->get_var($wpdb->prepare(
7467 - "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
7468 - $queue_id,
7469 - $meta_key
7470 - ));
7471 -
7472 - return maybe_unserialize($value);
7473 -}
7474 -
7475 -// ========================================
7476 -// AJAX QUEUE PROCESSING HANDLERS
7477 -// ========================================
7478 -
7479 -/**
7480 - * AJAX: Get next item from queue to process
7481 - */
7482 -public function ajax_mxchat_get_next_queue_item() {
7483 - // Verify nonce and permissions
7484 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7485 -
7486 - if (!current_user_can('manage_options')) {
7487 - wp_send_json_error('Unauthorized access');
7488 - }
7489 -
7490 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7491 -
7492 - if (empty($queue_id)) {
7493 - wp_send_json_error('Missing queue ID');
7494 - }
7495 -
7496 - global $wpdb;
7497 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7498 -
7499 - // Get next pending item with retry logic for failed items
7500 - $next_item = $wpdb->get_row($wpdb->prepare(
7501 - "SELECT * FROM $table_name
7502 - WHERE queue_id = %s
7503 - AND status IN ('pending', 'failed')
7504 - AND attempts < max_attempts
7505 - ORDER BY priority ASC, id ASC
7506 - LIMIT 1",
7507 - $queue_id
7508 - ));
7509 -
7510 - if (!$next_item) {
7511 - // No more items - queue complete
7512 - wp_send_json_success(array(
7513 - 'complete' => true,
7514 - 'message' => 'Queue processing complete'
7515 - ));
7516 - }
7517 -
7518 - // Mark item as processing
7519 - $wpdb->update(
7520 - $table_name,
7521 - array(
7522 - 'status' => 'processing',
7523 - 'started_at' => current_time('mysql'),
7524 - 'attempts' => $next_item->attempts + 1
7525 - ),
7526 - array('id' => $next_item->id),
7527 - array('%s', '%s', '%d'),
7528 - array('%d')
7529 - );
7530 -
7531 - wp_send_json_success(array(
7532 - 'complete' => false,
7533 - 'item' => array(
7534 - 'id' => $next_item->id,
7535 - 'type' => $next_item->item_type,
7536 - 'data' => json_decode($next_item->item_data, true),
7537 - 'bot_id' => $next_item->bot_id,
7538 - 'attempt' => $next_item->attempts + 1
7539 - )
7540 - ));
7541 -}
7542 -
7543 -/**
7544 - * AJAX: Process a single queue item
7545 - */
7546 -public function ajax_mxchat_process_queue_item() {
7547 - // Verify nonce and permissions
7548 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7549 -
7550 - if (!current_user_can('manage_options')) {
7551 - wp_send_json_error('Unauthorized access');
7552 - }
7553 -
7554 - $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
7555 - $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
7556 - $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
7557 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
7558 -
7559 - if (empty($item_id) || empty($item_type)) {
7560 - wp_send_json_error('Missing item data');
7561 - }
7562 -
7563 - global $wpdb;
7564 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7565 -
7566 - // Process based on item type
7567 - try {
7568 - set_time_limit(60); // Give processing 60 seconds
7569 -
7570 - $result = false;
7571 - $error_message = '';
7572 -
7573 - // Read item directly from DB to get queue_id and preserve special chars in item_data
7574 - // (POST round-trip through JS mangles characters like apostrophes in URLs)
7575 - $db_item = $wpdb->get_row($wpdb->prepare(
7576 - "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
7577 - $item_id
7578 - ));
7579 - $item_queue_id = $db_item ? $db_item->queue_id : '';
7580 - if ($db_item && !empty($db_item->item_data)) {
7581 - $db_data = json_decode($db_item->item_data, true);
7582 - if (is_array($db_data)) {
7583 - $item_data = $db_data;
7584 - }
7585 - }
7586 -
7587 - switch ($item_type) {
7588 - case 'url':
7589 - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
7590 - break;
7591 -
7592 - case 'pdf_page':
7593 - $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
7594 - break;
7595 -
7596 - default:
7597 - throw new Exception('Unknown item type: ' . $item_type);
7598 - }
7599 -
7600 - if (is_wp_error($result)) {
7601 - $error_code = $result->get_error_code();
7602 - // Content errors (empty page, sanitization) are permanent — retrying won't help
7603 - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
7604 - if (in_array($error_code, $permanent_codes)) {
7605 - // Mark as permanently failed — set attempts = max_attempts so it won't be retried
7606 - $current_item = $wpdb->get_row($wpdb->prepare(
7607 - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
7608 - ));
7609 - $wpdb->update(
7610 - $table_name,
7611 - array(
7612 - 'status' => 'failed',
7613 - 'error_message' => $result->get_error_message(),
7614 - 'attempts' => $current_item ? $current_item->max_attempts : 3
7615 - ),
7616 - array('id' => $item_id),
7617 - array('%s', '%s', '%d'),
7618 - array('%d')
7619 - );
7620 - wp_send_json_error(array(
7621 - 'message' => $result->get_error_message(),
7622 - 'permanent_failure' => true,
7623 - 'item_id' => $item_id
7624 - ));
7625 - return;
7626 - }
7627 - throw new Exception($result->get_error_message());
7628 - }
7629 -
7630 - if ($result === false) {
7631 - throw new Exception('Processing returned false - item may be empty or invalid');
7632 - }
7633 -
7634 - // Mark as completed
7635 - $wpdb->update(
7636 - $table_name,
7637 - array(
7638 - 'status' => 'completed',
7639 - 'completed_at' => current_time('mysql'),
7640 - 'error_message' => null
7641 - ),
7642 - array('id' => $item_id),
7643 - array('%s', '%s', '%s'),
7644 - array('%d')
7645 - );
7646 -
7647 - wp_send_json_success(array(
7648 - 'processed' => true,
7649 - 'item_id' => $item_id,
7650 - 'message' => 'Item processed successfully'
7651 - ));
7652 -
7653 - } catch (Exception $e) {
7654 - $error_message = $e->getMessage();
7655 -
7656 - // Get current attempt count
7657 - $item = $wpdb->get_row($wpdb->prepare(
7658 - "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
7659 - $item_id
7660 - ));
7661 -
7662 - // Check if we've exhausted retries
7663 - if ($item && $item->attempts >= $item->max_attempts) {
7664 - // Permanently failed
7665 - $wpdb->update(
7666 - $table_name,
7667 - array(
7668 - 'status' => 'failed',
7669 - 'error_message' => $error_message
7670 - ),
7671 - array('id' => $item_id),
7672 - array('%s', '%s'),
7673 - array('%d')
7674 - );
7675 -
7676 - wp_send_json_error(array(
7677 - 'message' => 'Item failed after maximum attempts: ' . $error_message,
7678 - 'permanent_failure' => true,
7679 - 'item_id' => $item_id
7680 - ));
7681 - } else {
7682 - // Mark for retry
7683 - $wpdb->update(
7684 - $table_name,
7685 - array(
7686 - 'status' => 'failed',
7687 - 'error_message' => $error_message
7688 - ),
7689 - array('id' => $item_id),
7690 - array('%s', '%s'),
7691 - array('%d')
7692 - );
7693 -
7694 - wp_send_json_error(array(
7695 - 'message' => 'Item processing failed, will retry: ' . $error_message,
7696 - 'can_retry' => true,
7697 - 'item_id' => $item_id,
7698 - 'attempts' => $item ? $item->attempts : 0
7699 - ));
7700 - }
7701 - }
7702 -}
7703 -
7704 -/**
7705 - * Process a URL from the queue
7706 - */
7707 -private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
7708 - $url = isset($item_data['url']) ? $item_data['url'] : '';
7709 -
7710 - if (empty($url)) {
7711 - return new WP_Error('invalid_url', 'URL is empty');
7712 - }
7713 -
7714 - // Get bot-specific API key early (needed for both paths)
7715 - $bot_options = $this->get_bot_options($bot_id);
7716 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7717 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7718 -
7719 - if (strpos($selected_model, 'voyage') === 0) {
7720 - $api_key = $options['voyage_api_key'] ?? '';
7721 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7722 - $api_key = $options['gemini_api_key'] ?? '';
7723 - } else {
7724 - $api_key = $options['api_key'] ?? '';
7725 - }
7726 -
7727 - if (empty($api_key)) {
7728 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7729 - }
7730 -
7731 - // Check if this is a WooCommerce product URL and WooCommerce is active
7732 - $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
7733 - $content_type = $is_product_url ? 'product' : 'url';
7734 -
7735 - // Try to get WooCommerce product data if it's a product URL
7736 - if ($is_product_url && class_exists('WooCommerce')) {
7737 - $product_content = $this->mxchat_extract_woocommerce_product_content($url);
7738 -
7739 - if (!empty($product_content)) {
7740 - // Successfully extracted WooCommerce product data with pricing
7741 - $result = MxChat_Utils::submit_content_to_db(
7742 - $product_content,
7743 - $url,
7744 - $api_key,
7745 - null,
7746 - $bot_id,
7747 - 'product'
7748 - );
7749 - return $result;
7750 - }
7751 - // If WooCommerce extraction failed, fall through to HTML extraction
7752 - }
7753 -
7754 - // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
7755 - $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7756 - $response = wp_remote_get($url, array(
7757 - 'timeout' => $is_likely_pdf ? 120 : 30,
7758 - 'redirection' => 5,
7759 - 'user-agent' => 'MxChat/1.0'
7760 - ));
7761 -
7762 - if (is_wp_error($response)) {
7763 - return $response;
7764 - }
7765 -
7766 - $response_code = wp_remote_retrieve_response_code($response);
7767 - if ($response_code !== 200) {
7768 - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
7769 - }
7770 -
7771 - // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
7772 - if ($this->mxchat_is_pdf_url($url, $response)) {
7773 - return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
7774 - }
7775 -
7776 - $html = wp_remote_retrieve_body($response);
7777 -
7778 - if (empty($html)) {
7779 - return new WP_Error('empty_response', 'Empty response body');
7780 - }
7781 -
7782 - // Extract and sanitize content
7783 - $content = $this->mxchat_extract_main_content($html);
7784 - $sanitized = $this->mxchat_sanitize_content_for_api($content);
7785 -
7786 - if (empty($sanitized)) {
7787 - // Not an error - just no content found (maybe a redirect or empty page)
7788 - return false;
7789 - }
7790 -
7791 - // Submit to database with content_type
7792 - $result = MxChat_Utils::submit_content_to_db(
7793 - $sanitized,
7794 - $url,
7795 - $api_key,
7796 - null,
7797 - $bot_id,
7798 - $content_type
7799 - );
7800 -
7801 - return $result;
7802 -}
7803 -
7804 -/**
7805 - * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
7806 - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
7807 - * and adds pdf_page items to the same queue so they process with full progress tracking.
7808 - */
7809 -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
7810 - set_time_limit(120); // PDFs need extra time for download + parsing
7811 -
7812 - $upload_dir = wp_upload_dir();
7813 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7814 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7815 -
7816 - $response_body = wp_remote_retrieve_body($response);
7817 - if (empty($response_body)) {
7818 - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
7819 - }
7820 -
7821 - if (!wp_mkdir_p(dirname($pdf_path))) {
7822 - return new WP_Error('dir_error', 'Failed to create upload directory');
7823 - }
7824 -
7825 - file_put_contents($pdf_path, $response_body);
7826 -
7827 - if (!file_exists($pdf_path)) {
7828 - return new WP_Error('save_error', 'Failed to save PDF file');
7829 - }
7830 -
7831 - try {
7832 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
7833 -
7834 - if ($total_pages === false || $total_pages < 1) {
7835 - wp_delete_file($pdf_path);
7836 - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
7837 - }
7838 -
7839 - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
7840 - $pages = array();
7841 - for ($i = 1; $i <= $total_pages; $i++) {
7842 - $pages[] = array(
7843 - 'pdf_path' => $pdf_path,
7844 - 'pdf_url' => $pdf_url,
7845 - 'page_number' => $i,
7846 - 'total_pages' => $total_pages
7847 - );
7848 - }
7849 -
7850 - // Add pdf_page items to the SAME queue so the JS picks them up automatically
7851 - if (!empty($queue_id)) {
7852 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
7853 - } else {
7854 - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
7855 - $new_queue_id = 'pdf_' . md5($pdf_url . time());
7856 - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
7857 - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
7858 - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
7859 - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
7860 - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
7861 - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
7862 - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
7863 - }
7864 -
7865 - if ($queued_count === 0) {
7866 - wp_delete_file($pdf_path);
7867 - return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
7868 - }
7869 -
7870 - // Return true so the original URL item is marked complete
7871 - // The new pdf_page items will be processed in subsequent batches
7872 - return true;
7873 -
7874 - } catch (Exception $e) {
7875 - if (file_exists($pdf_path)) {
7876 - wp_delete_file($pdf_path);
7877 - }
7878 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7879 - }
7880 -}
7881 -
7882 -/**
7883 - * Legacy: Process a PDF URL inline during sitemap queue processing.
7884 - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
7885 - */
7886 -private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
7887 - set_time_limit(120); // PDFs need more time — downloading + parsing all pages
7888 -
7889 - $upload_dir = wp_upload_dir();
7890 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7891 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7892 -
7893 - $response_body = wp_remote_retrieve_body($response);
7894 - if (empty($response_body)) {
7895 - return new WP_Error('empty_pdf', 'Empty PDF response');
7896 - }
7897 -
7898 - if (!wp_mkdir_p(dirname($pdf_path))) {
7899 - return new WP_Error('dir_error', 'Failed to create upload directory');
7900 - }
7901 -
7902 - file_put_contents($pdf_path, $response_body);
7903 -
7904 - if (!file_exists($pdf_path)) {
7905 - return new WP_Error('save_error', 'Failed to save PDF file');
7906 - }
7907 -
7908 - try {
7909 - mxchat_load_pdf_parser();
7910 - $parser = new \Smalot\PdfParser\Parser();
7911 - $pdf = $parser->parseFile($pdf_path);
7912 - $pages = $pdf->getPages();
7913 - $total_pages = count($pages);
7914 -
7915 - if ($total_pages < 1) {
7916 - wp_delete_file($pdf_path);
7917 - return new WP_Error('no_pages', 'PDF has no pages');
7918 - }
7919 -
7920 - $processed = 0;
7921 - $skipped_pages = array();
7922 -
7923 - for ($i = 0; $i < $total_pages; $i++) {
7924 - $page_num = $i + 1;
7925 - $text = $pages[$i]->getText();
7926 - if (empty($text)) {
7927 - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
7928 - continue;
7929 - }
7930 -
7931 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
7932 - if (empty($sanitized)) {
7933 - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
7934 - continue;
7935 - }
7936 -
7937 - $metadata = array(
7938 - 'document_type' => 'pdf',
7939 - 'total_pages' => $total_pages,
7940 - 'current_page' => $page_num,
7941 - 'source_url' => $pdf_url,
7942 - );
7943 -
7944 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
7945 - $page_url = esc_url($pdf_url . '#page=' . $page_num);
7946 -
7947 - MxChat_Utils::submit_content_to_db(
7948 - $content_with_metadata,
7949 - $page_url,
7950 - $api_key,
7951 - null,
7952 - $bot_id,
7953 - 'pdf'
7954 - );
7955 -
7956 - $processed++;
7957 - }
7958 -
7959 - // Clean up the temp PDF file
7960 - wp_delete_file($pdf_path);
7961 -
7962 - if (!empty($skipped_pages)) {
7963 - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
7964 - }
7965 -
7966 - return $processed > 0 ? true : false;
7967 -
7968 - } catch (Exception $e) {
7969 - if (file_exists($pdf_path)) {
7970 - wp_delete_file($pdf_path);
7971 - }
7972 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7973 - }
7974 -}
7975 -
7976 -/**
7977 - * Extract WooCommerce product content including pricing
7978 - *
7979 - * @param string $url The product URL
7980 - * @return string|false Product content with pricing, or false if not found
7981 - */
7982 -private function mxchat_extract_woocommerce_product_content($url) {
7983 - // Try to get product ID from URL
7984 - $product_id = url_to_postid($url);
7985 -
7986 - // If url_to_postid fails, try to extract from URL pattern
7987 - if (!$product_id) {
7988 - $product_slug = '';
7989 -
7990 - // Handle pretty permalinks: /product/product-name/
7991 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
7992 - $product_slug = $matches[1];
7993 - }
7994 -
7995 - if (!empty($product_slug)) {
7996 - $product_post = get_page_by_path($product_slug, OBJECT, 'product');
7997 - if ($product_post) {
7998 - $product_id = $product_post->ID;
7999 - }
8000 - }
8001 - }
8002 -
8003 - if (!$product_id) {
8004 - return false;
8005 - }
8006 -
8007 - // Get WooCommerce product object
8008 - $product = wc_get_product($product_id);
8009 -
8010 - if (!$product) {
8011 - return false;
8012 - }
8013 -
8014 - // Build product content with pricing (similar to mxchat_store_product_embedding)
8015 - $title = $product->get_name();
8016 - $description = $product->get_description();
8017 - $short_description = $product->get_short_description();
8018 - $sku = $product->get_sku();
8019 -
8020 - // Get pricing information
8021 - $regular_price = $product->get_regular_price();
8022 - $sale_price = $product->get_sale_price();
8023 - $price = $product->get_price(); // Current active price
8024 -
8025 - // Get currency symbol
8026 - $currency_symbol = get_woocommerce_currency_symbol();
8027 -
8028 - // Format content
8029 - $content = $title . "\n\n";
8030 -
8031 - if (!empty($short_description)) {
8032 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
8033 - }
8034 -
8035 - if (!empty($description)) {
8036 - $content .= wp_strip_all_tags($description) . "\n\n";
8037 - }
8038 -
8039 - // Add pricing information
8040 - if (!empty($regular_price)) {
8041 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
8042 - } elseif (!empty($price)) {
8043 - $content .= "Price: " . $currency_symbol . $price . "\n";
8044 - }
8045 -
8046 - if (!empty($sale_price) && $sale_price !== $regular_price) {
8047 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
8048 - }
8049 -
8050 - // Handle variable products - show price range
8051 - if ($product->is_type('variable')) {
8052 - $min_price = $product->get_variation_price('min');
8053 - $max_price = $product->get_variation_price('max');
8054 - if ($min_price !== $max_price) {
8055 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
8056 - }
8057 - }
8058 -
8059 - if (!empty($sku)) {
8060 - $content .= "SKU: " . $sku . "\n";
8061 - }
8062 -
8063 - // Get product categories
8064 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
8065 - if (!empty($categories) && !is_wp_error($categories)) {
8066 - $content .= "Categories: " . implode(', ', $categories) . "\n";
8067 - }
8068 -
8069 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
8070 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
8071 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
8072 - foreach ($custom_tabs as $tab) {
8073 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
8074 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
8075 -
8076 - if (!empty($tab_title) && !empty($tab_content)) {
8077 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
8078 - }
8079 - }
8080 - }
8081 -
8082 - // Also check for reusable/saved tabs applied to this product
8083 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
8084 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
8085 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
8086 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
8087 - foreach ($applied_saved_tabs as $saved_tab_id) {
8088 - if (isset($saved_tabs[$saved_tab_id])) {
8089 - $tab = $saved_tabs[$saved_tab_id];
8090 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
8091 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
8092 -
8093 - if (!empty($tab_title) && !empty($tab_content)) {
8094 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
8095 - }
8096 - }
8097 - }
8098 - }
8099 - }
8100 -
8101 - return $this->mxchat_sanitize_content_for_api($content);
8102 -}
8103 -
8104 -/**
8105 - * Process a PDF page from the queue
8106 - */
8107 -private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
8108 - $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
8109 - $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
8110 - $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
8111 - $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
8112 -
8113 - if (empty($pdf_path) || !file_exists($pdf_path)) {
8114 - return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
8115 - }
8116 -
8117 - if ($page_number < 1) {
8118 - return new WP_Error('invalid_page', 'Invalid page number');
8119 - }
8120 -
8121 - try {
8122 - mxchat_load_pdf_parser();
8123 - $parser = new \Smalot\PdfParser\Parser();
8124 - $pdf = $parser->parseFile($pdf_path);
8125 - $pages = $pdf->getPages();
8126 -
8127 - if (!isset($pages[$page_number - 1])) {
8128 - return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
8129 - }
8130 -
8131 - $text = $pages[$page_number - 1]->getText();
8132 -
8133 - if (empty($text)) {
8134 - return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
8135 - }
8136 -
8137 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
8138 -
8139 - if (empty($sanitized)) {
8140 - 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');
8141 - }
8142 -
8143 - // Create metadata
8144 - $metadata = array(
8145 - 'document_type' => 'pdf',
8146 - 'total_pages' => $total_pages,
8147 - 'current_page' => $page_number,
8148 - 'source_url' => $pdf_url
8149 - );
8150 -
8151 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
8152 - $page_url = esc_url($pdf_url . "#page=" . $page_number);
8153 -
8154 - // Get bot-specific API key
8155 - $bot_options = $this->get_bot_options($bot_id);
8156 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
8157 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
8158 -
8159 - if (strpos($selected_model, 'voyage') === 0) {
8160 - $api_key = $options['voyage_api_key'] ?? '';
8161 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
8162 - $api_key = $options['gemini_api_key'] ?? '';
8163 - } else {
8164 - $api_key = $options['api_key'] ?? '';
8165 - }
8166 -
8167 - if (empty($api_key)) {
8168 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
8169 - }
8170 -
8171 - // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
8172 - $result = MxChat_Utils::submit_content_to_db(
8173 - $content_with_metadata,
8174 - $page_url,
8175 - $api_key,
8176 - null,
8177 - $bot_id,
8178 - 'pdf'
8179 - );
8180 -
8181 - return $result;
8182 -
8183 - } catch (Exception $e) {
8184 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
8185 - }
8186 -}
8187 -
8188 -/**
8189 - * AJAX: Get queue processing status
8190 - */
8191 -public function ajax_mxchat_get_queue_status() {
8192 - // Verify nonce and permissions
8193 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8194 -
8195 - if (!current_user_can('manage_options')) {
8196 - wp_send_json_error('Unauthorized access');
8197 - }
8198 -
8199 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8200 -
8201 - if (empty($queue_id)) {
8202 - wp_send_json_error('Missing queue ID');
8203 - }
8204 -
8205 - global $wpdb;
8206 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8207 -
8208 - // Get counts by status
8209 - $counts = $wpdb->get_results($wpdb->prepare(
8210 - "SELECT status, COUNT(*) as count
8211 - FROM $table_name
8212 - WHERE queue_id = %s
8213 - GROUP BY status",
8214 - $queue_id
8215 - ), OBJECT_K);
8216 -
8217 - $total = 0;
8218 - $completed = 0;
8219 - $failed = 0;
8220 - $processing = 0;
8221 - $pending = 0;
8222 -
8223 - foreach ($counts as $status => $data) {
8224 - $count = absint($data->count);
8225 - $total += $count;
8226 -
8227 - switch ($status) {
8228 - case 'completed':
8229 - $completed = $count;
8230 - break;
8231 - case 'failed':
8232 - $failed = $count;
8233 - break;
8234 - case 'processing':
8235 - $processing = $count;
8236 - break;
8237 - case 'pending':
8238 - $pending = $count;
8239 - break;
8240 - }
8241 - }
8242 -
8243 - // Calculate percentage
8244 - $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
8245 -
8246 - // Get failed items details (include all failed items, not just those that exhausted retries)
8247 - $failed_items = array();
8248 - if ($failed > 0) {
8249 - $failed_items = $wpdb->get_results($wpdb->prepare(
8250 - "SELECT item_type, item_data, error_message, attempts
8251 - FROM $table_name
8252 - WHERE queue_id = %s
8253 - AND status = 'failed'
8254 - ORDER BY id DESC
8255 - LIMIT 50",
8256 - $queue_id
8257 - ));
8258 - }
8259 -
8260 - // Get queue metadata
8261 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
8262 - $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
8263 -
8264 - // Determine if queue is complete
8265 - $is_complete = ($pending === 0 && $processing === 0);
8266 -
8267 - wp_send_json_success(array(
8268 - 'queue_id' => $queue_id,
8269 - 'queue_type' => $queue_type,
8270 - 'source_url' => $source_url,
8271 - 'total' => $total,
8272 - 'completed' => $completed,
8273 - 'failed' => $failed,
8274 - 'processing' => $processing,
8275 - 'pending' => $pending,
8276 - 'percentage' => $percentage,
8277 - 'is_complete' => $is_complete,
8278 - 'failed_items' => $failed_items,
8279 - 'status' => $is_complete ? 'complete' : 'processing'
8280 - ));
8281 -}
8282 -
8283 -/**
8284 - * AJAX: Clear completed queue
8285 - */
8286 -public function ajax_mxchat_clear_queue() {
8287 - // Verify nonce and permissions
8288 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8289 -
8290 - if (!current_user_can('manage_options')) {
8291 - wp_send_json_error('Unauthorized access');
8292 - }
8293 -
8294 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8295 -
8296 - if (empty($queue_id)) {
8297 - wp_send_json_error('Missing queue ID');
8298 - }
8299 -
8300 - global $wpdb;
8301 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8302 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
8303 -
8304 - // Delete queue items
8305 - $wpdb->delete(
8306 - $table_name,
8307 - array('queue_id' => $queue_id),
8308 - array('%s')
8309 - );
8310 -
8311 - // Delete queue metadata
8312 - $wpdb->delete(
8313 - $meta_table,
8314 - array('queue_id' => $queue_id),
8315 - array('%s')
8316 - );
8317 -
8318 - wp_send_json_success(array(
8319 - 'message' => 'Queue cleared successfully'
8320 - ));
8321 -}
8322 -
8323 -/**
8324 - * AJAX: Retry failed items in queue
8325 - */
8326 -public function ajax_mxchat_retry_failed() {
8327 - // Verify nonce and permissions
8328 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8329 -
8330 - if (!current_user_can('manage_options')) {
8331 - wp_send_json_error('Unauthorized access');
8332 - }
8333 -
8334 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8335 -
8336 - if (empty($queue_id)) {
8337 - wp_send_json_error('Missing queue ID');
8338 - }
8339 -
8340 - global $wpdb;
8341 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8342 -
8343 - // Reset failed items to pending and reset attempt count
8344 - $updated = $wpdb->update(
8345 - $table_name,
8346 - array(
8347 - 'status' => 'pending',
8348 - 'attempts' => 0,
8349 - 'error_message' => null
8350 - ),
8351 - array(
8352 - 'queue_id' => $queue_id,
8353 - 'status' => 'failed'
8354 - ),
8355 - array('%s', '%d', '%s'),
8356 - array('%s', '%s')
8357 - );
8358 -
8359 - wp_send_json_success(array(
8360 - 'message' => 'Reset ' . $updated . ' failed items for retry',
8361 - 'reset_count' => $updated
8362 - ));
8363 -}
8364 -
8365 -
8366 -public function ajax_mxchat_mark_queue_complete() {
8367 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8368 -
8369 - if (!current_user_can('manage_options')) {
8370 - wp_send_json_error('Unauthorized access');
8371 - }
8372 -
8373 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8374 -
8375 - if (empty($queue_id)) {
8376 - wp_send_json_error('Missing queue ID');
8377 - }
8378 -
8379 - // Clear active queue transients
8380 - if (strpos($queue_id, 'sitemap_') === 0) {
8381 - delete_transient('mxchat_active_queue_sitemap');
8382 - } else if (strpos($queue_id, 'pdf_') === 0) {
8383 - delete_transient('mxchat_active_queue_pdf');
8384 - }
8385 -
8386 - wp_send_json_success(array('message' => 'Queue marked as complete'));
8387 -}
8388 -
8389 -
8390 - // ========================================
8391 - // STATIC ACCESS METHODS
8392 - // ========================================
8393 -
8394 - /**
8395 - * Get singleton instance
8396 - */
8397 - public static function get_instance() {
8398 - static $instance = null;
8399 - if ($instance === null) {
8400 - $instance = new self();
8401 - }
8402 - return $instance;
8403 - }
8404 -}
8405 -
8406 -// Initialize the Knowledge manager
1 +<?php
2 +/**
3 + * File: admin/class-knowledge-manager.php
4 + *
5 + * Handles all knowledge base content processing for MxChat
6 + * Including PDF, sitemap, content processing, and WordPress post management
7 + */
8 +if (!defined('ABSPATH')) {
9 + exit; // Exit if accessed directly
10 +}
11 +
12 +class MxChat_Knowledge_Manager {
13 +
14 + private $options;
15 +
16 + /**
17 + * Constructor - Register hooks for content processing
18 + */
19 +public function __construct() {
20 + $this->options = get_option('mxchat_options', array());
21 + $this->mxchat_init_hooks();
22 +
23 + $this->mxchat_init_role_hooks();
24 +}
25 +
26 +/**
27 + * Initialize WordPress hooks for content processing
28 + *
29 + */
30 +private function mxchat_init_hooks() {
31 + // Admin post handlers for form submissions
32 + add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 + add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 + add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
35 +
36 + // AJAX handlers for real-time processing and status updates
37 + add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
38 + add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
39 + add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
40 + add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
41 + add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
42 + add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
43 + add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
44 + add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
45 +
46 + // Queue-based processing AJAX handlers
47 + add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
48 + add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
49 + add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
50 + add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
51 + add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
52 +
53 + // Hook for content deletion
54 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
55 +
56 + // WordPress post management hooks
57 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
58 + add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
59 + add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
60 + add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
61 +
62 + add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
63 +
64 + // WooCommerce product hooks (if WooCommerce is active)
65 + if (class_exists('WooCommerce')) {
66 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
67 + add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
68 + add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
69 + add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
70 + }
71 +}
72 +
73 + /**
74 + * Get current options (refreshed)
75 + */
76 + private function mxchat_get_options() {
77 + if (empty($this->options)) {
78 + $this->options = get_option('mxchat_options', array());
79 + }
80 + return $this->options;
81 + }
82 +
83 +
84 + // ========================================
85 + // MAIN CONTENT SUBMISSION HANDLERS
86 + // ========================================
87 +
88 +public function mxchat_handle_content_submission() {
89 + // Check if the form was submitted and the user has permission.
90 + if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
91 + return;
92 + }
93 +
94 + // Verify the nonce.
95 + $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
96 + if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
97 + wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
98 + }
99 +
100 + // Sanitize the inputs.
101 + $article_content = sanitize_textarea_field($_POST['article_content']);
102 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
103 +
104 + // Get bot_id from form submission
105 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
106 +
107 + // Get bot-specific options and API key
108 + $bot_options = $this->get_bot_options($bot_id);
109 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
110 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
111 +
112 + if (strpos($selected_model, 'voyage') === 0) {
113 + $api_key = $options['voyage_api_key'] ?? '';
114 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
115 + $api_key = $options['gemini_api_key'] ?? '';
116 + } else {
117 + $api_key = $options['api_key'] ?? '';
118 + }
119 +
120 + if (empty($api_key)) {
121 + set_transient('mxchat_admin_notice_error',
122 + esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
123 + 30
124 + );
125 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
126 + exit;
127 + }
128 +
129 + // Use centralized utility function with bot_id
130 + $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
131 +
132 + if (is_wp_error($result)) {
133 + set_transient('mxchat_admin_notice_error',
134 + esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
135 + 30
136 + );
137 + } else {
138 + set_transient('mxchat_admin_notice_success',
139 + esc_html__('Content successfully submitted!', 'mxchat'),
140 + 30
141 + );
142 + }
143 +
144 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
145 + exit;
146 +}
147 +
148 +public function mxchat_is_pdf_url($url, $response) {
149 + $content_type = wp_remote_retrieve_header($response, 'content-type');
150 + $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
151 +
152 + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf';
153 +}
154 +
155 +
156 +public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
157 + if (!current_user_can('manage_options')) {
158 + return false;
159 + }
160 +
161 + $pdf_url = esc_url_raw($pdf_url);
162 + $upload_dir = wp_upload_dir();
163 +
164 + if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
165 + return false;
166 + }
167 +
168 + $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
169 + $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
170 +
171 + $response_body = wp_remote_retrieve_body($response);
172 + if (empty($response_body)) {
173 + return false;
174 + }
175 +
176 + if (!wp_mkdir_p(dirname($pdf_path))) {
177 + return false;
178 + }
179 +
180 + try {
181 + file_put_contents($pdf_path, $response_body);
182 +
183 + if (!file_exists($pdf_path)) {
184 + throw new Exception(__('Failed to save PDF file', 'mxchat'));
185 + }
186 +
187 + $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
188 +
189 + if ($total_pages === false || $total_pages < 1) {
190 + throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
191 + }
192 +
193 + // Create unique queue ID
194 + $queue_id = 'pdf_' . md5($pdf_url . time());
195 +
196 + // Create array of pages to process
197 + $pages = array();
198 + for ($i = 1; $i <= $total_pages; $i++) {
199 + $pages[] = array(
200 + 'pdf_path' => $pdf_path,
201 + 'pdf_url' => $pdf_url,
202 + 'page_number' => $i,
203 + 'total_pages' => $total_pages
204 + );
205 + }
206 +
207 + // Add pages to queue
208 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
209 +
210 + if ($queued_count === 0) {
211 + wp_delete_file($pdf_path);
212 + throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
213 + }
214 +
215 + // Store queue metadata
216 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
217 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
218 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
219 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
220 + $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
221 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
222 +
223 + // Store queue ID in transient for status tracking
224 + set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
225 + set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
226 +
227 + return 'queued';
228 +
229 + } catch (Exception $e) {
230 + if (file_exists($pdf_path)) {
231 + wp_delete_file($pdf_path);
232 + }
233 + return $e->getMessage();
234 + }
235 +}
236 +
237 +/**
238 + * Validate PDF and count pages with multiple parser attempts
239 + */
240 +private function mxchat_validate_and_count_pdf_pages($pdf_path) {
241 + // Method 1: Try with Smalot PDF Parser (your current method)
242 + try {
243 + $parser = new \Smalot\PdfParser\Parser();
244 + $pdf = $parser->parseFile($pdf_path);
245 + $pages = $pdf->getPages();
246 + $page_count = count($pages);
247 +
248 + if ($page_count > 0) {
249 + //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
250 + return $page_count;
251 + }
252 + } catch (Exception $e) {
253 + //error_log('Smalot PDF parser failed: ' . $e->getMessage());
254 + }
255 +
256 + // Method 2: Try with pdfinfo command (if available)
257 + if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
258 + try {
259 + $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
260 + $output = shell_exec($command);
261 +
262 + if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
263 + $page_count = intval($matches[1]);
264 + if ($page_count > 0) {
265 + //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
266 + return $page_count;
267 + }
268 + }
269 + } catch (Exception $e) {
270 + //error_log('pdfinfo command failed: ' . $e->getMessage());
271 + }
272 + }
273 +
274 + // Method 3: Try to repair PDF and parse again
275 + try {
276 + $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
277 + if ($repaired_path && $repaired_path !== $pdf_path) {
278 + $parser = new \Smalot\PdfParser\Parser();
279 + $pdf = $parser->parseFile($repaired_path);
280 + $pages = $pdf->getPages();
281 + $page_count = count($pages);
282 +
283 + if ($page_count > 0) {
284 + // Replace original with repaired version
285 + copy($repaired_path, $pdf_path);
286 + unlink($repaired_path);
287 + //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
288 + return $page_count;
289 + }
290 +
291 + // Clean up repaired file if it didn't work
292 + unlink($repaired_path);
293 + }
294 + } catch (Exception $e) {
295 + //error_log('PDF repair attempt failed: ' . $e->getMessage());
296 + }
297 +
298 + // Method 4: Manual PDF structure analysis (basic page count)
299 + try {
300 + $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
301 + if ($page_count > 0) {
302 + //error_log('PDF page count determined manually: ' . $page_count . ' pages');
303 + return $page_count;
304 + }
305 + } catch (Exception $e) {
306 + //error_log('Manual PDF analysis failed: ' . $e->getMessage());
307 + }
308 +
309 + //error_log('All PDF parsing methods failed for: ' . $pdf_path);
310 + return false;
311 +}
312 +
313 +/**
314 + * Check if shell_exec is disabled
315 + */
316 +private function mxchat_is_shell_disabled() {
317 + $disabled = explode(',', ini_get('disable_functions'));
318 + return in_array('shell_exec', $disabled);
319 +}
320 +
321 +/**
322 + * Attempt to repair PDF using basic methods
323 + */
324 +private function mxchat_attempt_pdf_repair($pdf_path) {
325 + try {
326 + $content = file_get_contents($pdf_path);
327 + if (!$content) {
328 + return false;
329 + }
330 +
331 + // Check if PDF starts with proper header
332 + if (substr($content, 0, 4) !== '%PDF') {
333 + // Try to find PDF header in the content
334 + $header_pos = strpos($content, '%PDF');
335 + if ($header_pos !== false && $header_pos < 1024) {
336 + // Remove junk before PDF header
337 + $content = substr($content, $header_pos);
338 + $repaired_path = $pdf_path . '.repaired';
339 + file_put_contents($repaired_path, $content);
340 + return $repaired_path;
341 + }
342 + }
343 +
344 + // Check for EOF marker
345 + $content = rtrim($content);
346 + if (!preg_match('/%%EOF\s*$/', $content)) {
347 + // Add EOF marker if missing
348 + $content .= "\n%%EOF";
349 + $repaired_path = $pdf_path . '.repaired';
350 + file_put_contents($repaired_path, $content);
351 + return $repaired_path;
352 + }
353 +
354 + } catch (Exception $e) {
355 + //error_log('PDF repair error: ' . $e->getMessage());
356 + }
357 +
358 + return false;
359 +}
360 +
361 +/**
362 + * Manual PDF page counting by analyzing PDF structure
363 + */
364 +private function mxchat_manual_pdf_page_count($pdf_path) {
365 + try {
366 + $content = file_get_contents($pdf_path);
367 + if (!$content) {
368 + return 0;
369 + }
370 +
371 + // Method 1: Count /Type /Page objects
372 + $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
373 + if ($page_count > 0) {
374 + return $page_count;
375 + }
376 +
377 + // Method 2: Look for /Count in pages object
378 + if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
379 + return intval($matches[1]);
380 + }
381 +
382 + // Method 3: Count page references
383 + $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
384 + if ($page_count > 0) {
385 + return $page_count;
386 + }
387 +
388 + } catch (Exception $e) {
389 + //error_log('Manual PDF analysis error: ' . $e->getMessage());
390 + }
391 +
392 + return 0;
393 +}
394 +
395 +
396 +public function mxchat_save_inline_prompt() {
397 + // DEBUG: Log what we're receiving
398 + //error_log('=== MXCHAT DEBUG ===');
399 + //error_log('POST data: ' . print_r($_POST, true));
400 + //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
401 +
402 + // Check for nonce security
403 + check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
404 +
405 + // If we get here, nonce passed
406 + //error_log('Nonce verification PASSED');
407 +
408 + // Verify permissions
409 + if (!current_user_can('manage_options')) {
410 + wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
411 + return;
412 + }
413 +
414 + global $wpdb;
415 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
416 +
417 + // Validate and sanitize input data - FIXED LINE BELOW
418 + $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
419 + $article_content = isset($_POST['article_content']) ? sanitize_textarea_field(wp_unslash($_POST['article_content'])) : '';
420 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
421 +
422 + if ($prompt_id > 0 && !empty($article_content)) {
423 + // Re-generate the embedding vector for the updated content
424 + $embedding_vector = $this->mxchat_generate_embedding($article_content);
425 + if (is_array($embedding_vector)) {
426 + // Serialize the embedding vector before storing it
427 + $embedding_vector_serialized = serialize($embedding_vector);
428 + // Update the prompt in the database
429 + $updated = $wpdb->update(
430 + $table_name,
431 + array(
432 + 'article_content' => $article_content,
433 + 'embedding_vector' => $embedding_vector_serialized,
434 + 'source_url' => $article_url,
435 + ),
436 + array('id' => $prompt_id),
437 + array('%s', '%s', '%s'),
438 + array('%d')
439 + );
440 + if ($updated !== false) {
441 + wp_send_json_success();
442 + } else {
443 + wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
444 + }
445 + } else {
446 + wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
447 + }
448 + } else {
449 + wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
450 + }
451 +}
452 +
453 +
454 +public function mxchat_get_pdf_processing_status($pdf_url) {
455 + $pdf_url = esc_url_raw($pdf_url);
456 + $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
457 +
458 + if (!$status || !is_array($status)) {
459 + return false;
460 + }
461 +
462 + // Check for stalled processing (no updates for 5 minutes)
463 + if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
464 + $status['status'] = 'error';
465 + $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
466 +
467 + // Save the updated status
468 + set_transient(
469 + sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
470 + array_map('sanitize_text_field', $status),
471 + DAY_IN_SECONDS
472 + );
473 + }
474 +
475 + $result = array(
476 + 'total_pages' => absint($status['total_pages']),
477 + 'processed_pages' => absint($status['processed_pages']),
478 + 'failed_pages' => absint($status['failed_pages'] ?? 0),
479 + 'percentage' => ($status['total_pages'] > 0)
480 + ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
481 + : 0,
482 + 'status' => sanitize_text_field($status['status']),
483 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
484 + 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
485 + 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
486 + );
487 +
488 + // Add error message if present
489 + if (isset($status['error']) && !empty($status['error'])) {
490 + $result['error'] = sanitize_text_field($status['error']);
491 + }
492 +
493 + return $result;
494 +}
495 +
496 +
497 +public function mxchat_handle_sitemap_submission() {
498 + // Check if the form was submitted and verify permissions
499 + if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
500 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
501 + }
502 +
503 + // Verify nonce
504 + check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
505 +
506 + // Validate URL
507 + if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
508 + set_transient('mxchat_admin_notice_error',
509 + esc_html__('Please provide a valid URL.', 'mxchat'),
510 + 30
511 + );
512 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
513 + exit;
514 + }
515 +
516 + $submitted_url = esc_url_raw($_POST['sitemap_url']);
517 +
518 + // Get bot_id from form submission
519 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
520 +
521 + // Get bot-specific options and validate API key
522 + $bot_options = $this->get_bot_options($bot_id);
523 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
524 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
525 +
526 + if (strpos($selected_model, 'voyage') === 0) {
527 + $api_key = $options['voyage_api_key'] ?? '';
528 + $provider_name = 'Voyage AI';
529 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
530 + $api_key = $options['gemini_api_key'] ?? '';
531 + $provider_name = 'Google Gemini';
532 + } else {
533 + $api_key = $options['api_key'] ?? '';
534 + $provider_name = 'OpenAI';
535 + }
536 +
537 + if (empty($api_key)) {
538 + $error_message = sprintf(
539 + esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
540 + $provider_name
541 + );
542 + set_transient('mxchat_admin_notice_error', $error_message, 30);
543 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
544 + exit;
545 + }
546 +
547 + // Fetch URL
548 + $response = wp_remote_get($submitted_url, array('timeout' => 30));
549 +
550 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
551 + $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
552 + set_transient('mxchat_admin_notice_error',
553 + sprintf(
554 + esc_html__('Failed to fetch the URL: %s', 'mxchat'),
555 + esc_html($error_message)
556 + ),
557 + 30
558 + );
559 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
560 + exit;
561 + }
562 +
563 + $content_type = wp_remote_retrieve_header($response, 'content-type');
564 + $body_content = wp_remote_retrieve_body($response);
565 +
566 + if (empty($body_content)) {
567 + set_transient('mxchat_admin_notice_error',
568 + esc_html__('Empty response received from URL.', 'mxchat'),
569 + 30
570 + );
571 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
572 + exit;
573 + }
574 +
575 + // Handle PDF URL
576 + if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
577 + $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
578 +
579 + if ($result === 'queued') {
580 + set_transient('mxchat_admin_notice_success',
581 + esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
582 + 30
583 + );
584 + } else {
585 + set_transient('mxchat_admin_notice_error',
586 + esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
587 + 30
588 + );
589 + }
590 +
591 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
592 + exit;
593 + }
594 +
595 + // Handle Sitemap XML
596 + if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
597 + libxml_use_internal_errors(true);
598 + $xml = simplexml_load_string($body_content);
599 + $xml_errors = libxml_get_errors();
600 + libxml_clear_errors();
601 +
602 + if ($xml === false || !empty($xml_errors)) {
603 + set_transient('mxchat_admin_notice_error',
604 + esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
605 + 30
606 + );
607 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
608 + exit;
609 + }
610 +
611 + $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
612 +
613 + if ($result === 'queued') {
614 + set_transient('mxchat_admin_notice_success',
615 + esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
616 + 30
617 + );
618 + } else {
619 + set_transient('mxchat_admin_notice_error',
620 + esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
621 + 30
622 + );
623 + }
624 +
625 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
626 + exit;
627 + }
628 +
629 + // Handle Regular URL (single page)
630 + $page_content = $this->mxchat_extract_main_content($body_content);
631 + $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
632 +
633 + if (empty($sanitized_content)) {
634 + set_transient('mxchat_admin_notice_error',
635 + esc_html__('No valid content found on the provided URL.', 'mxchat'),
636 + 30
637 + );
638 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
639 + exit;
640 + }
641 +
642 + // For single URLs, process immediately (not queued)
643 + $embedding_vector = $this->mxchat_generate_embedding($sanitized_content, $bot_id);
644 +
645 + if (is_string($embedding_vector)) {
646 + $error_message = esc_html__('Failed to generate embedding: ', 'mxchat') . esc_html($embedding_vector);
647 + set_transient('mxchat_admin_notice_error', $error_message, 30);
648 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
649 + exit;
650 + }
651 +
652 + if (is_array($embedding_vector)) {
653 + $db_result = MxChat_Utils::submit_content_to_db(
654 + $sanitized_content,
655 + $submitted_url,
656 + $api_key,
657 + null,
658 + $bot_id
659 + );
660 +
661 + if (is_wp_error($db_result)) {
662 + $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
663 + set_transient('mxchat_admin_notice_error', $error_message, 30);
664 + } else {
665 + $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
666 + set_transient('mxchat_admin_notice_success', $success_message, 30);
667 + }
668 + } else {
669 + $error_message = esc_html__('Failed to generate embedding: Unexpected result type. Please check your API key and try again.', 'mxchat');
670 + set_transient('mxchat_admin_notice_error', $error_message, 30);
671 + }
672 +
673 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
674 + exit;
675 +}
676 +
677 +
678 +public function mxchat_get_single_url_status() {
679 + $status = get_transient('mxchat_single_url_status');
680 + if (!$status) {
681 + return null;
682 + }
683 +
684 + // Add human-readable time
685 + if (isset($status['timestamp'])) {
686 + $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
687 + }
688 +
689 + return $status;
690 +}
691 +
692 +public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
693 + if (!current_user_can('manage_options')) {
694 + return false;
695 + }
696 +
697 + try {
698 + $sitemap_url = esc_url_raw($sitemap_url);
699 +
700 + if (!$xml || !is_object($xml)) {
701 + throw new Exception(__('Invalid XML object provided', 'mxchat'));
702 + }
703 +
704 + // Get bot-specific embedding API for validation
705 + $bot_options = $this->get_bot_options($bot_id);
706 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
707 +
708 + // Test the embedding API before processing
709 + $test_phrase = "Test embedding generation for MxChat";
710 + $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
711 +
712 + if (is_string($test_result)) {
713 + throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
714 + }
715 +
716 + if (!is_array($test_result)) {
717 + throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
718 + }
719 +
720 + // Extract URLs from sitemap
721 + $urls = array();
722 + foreach ($xml->url as $url_element) {
723 + $url = esc_url_raw((string)$url_element->loc);
724 + if ($url) {
725 + $urls[] = array('url' => $url);
726 + }
727 + }
728 +
729 + $total_urls = count($urls);
730 +
731 + if ($total_urls < 1) {
732 + throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
733 + }
734 +
735 + // Create unique queue ID
736 + $queue_id = 'sitemap_' . md5($sitemap_url . time());
737 +
738 + // Add URLs to queue
739 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
740 +
741 + if ($queued_count === 0) {
742 + throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
743 + }
744 +
745 + // Store queue metadata
746 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
747 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
748 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
749 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
750 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
751 +
752 + // Store queue ID in transient for status tracking
753 + set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
754 + set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
755 +
756 + return 'queued';
757 +
758 + } catch (Exception $e) {
759 + $error_message = $e->getMessage();
760 + error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
761 +
762 + return $error_message;
763 + }
764 +
765 +}
766 +
767 +public function mxchat_sanitize_content_for_api($content) {
768 + //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
769 +
770 + // Remove script, style tags, and HTML comments
771 + $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
772 + $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
773 + $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
774 +
775 + // Remove all HTML tags and decode HTML entities
776 + $content = wp_strip_all_tags($content);
777 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
778 +
779 + // Normalize whitespace but preserve paragraph breaks
780 + // First, normalize line endings to \n
781 + $content = str_replace(["\r\n", "\r"], "\n", $content);
782 + // Replace multiple spaces/tabs with single space, but preserve newlines
783 + $content = preg_replace('/[ \t]+/', ' ', $content);
784 + // Replace 3+ newlines with 2 newlines (max 2 blank lines)
785 + $content = preg_replace('/\n{3,}/', "\n\n", $content);
786 + // Trim each line
787 + $lines = explode("\n", $content);
788 + $lines = array_map('trim', $lines);
789 + $content = implode("\n", $lines);
790 + // Final trim
791 + $content = trim($content);
792 +
793 + // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
794 + $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
795 +
796 + // Remove NULL bytes which can cause database errors
797 + $content = str_replace("\0", "", $content);
798 +
799 + // Ensure valid UTF-8 encoding
800 + $content = wp_check_invalid_utf8($content);
801 +
802 + // Remove any extremely long strings without spaces (often garbage)
803 + $content = preg_replace('/\S{300,}/', ' ', $content);
804 +
805 + // Replace problematic characters that often cause database issues
806 + $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
807 +
808 + // Replace any remaining potentially problematic characters with spaces
809 + // BUT preserve newlines by temporarily replacing them
810 + $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
811 + $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
812 + $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
813 +
814 + // Limit to reasonable length if needed
815 + $max_length = 65000; // Just under MySQL TEXT field limit
816 + if (strlen($content) > $max_length) {
817 + $content = substr($content, 0, $max_length);
818 + }
819 +
820 + //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
821 + return $content;
822 +}
823 +public function mxchat_extract_main_content($html) {
824 + if (empty($html)) {
825 + return '';
826 + }
827 + try {
828 + $dom = new DOMDocument;
829 + libxml_use_internal_errors(true); // Suppress HTML parsing errors
830 + @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
831 + $xpath = new DOMXPath($dom);
832 +
833 + // For debugging purposes
834 + $debugEnabled = false; // Set to true to enable debugging output
835 + $debug = function($message) use ($debugEnabled) {
836 + if ($debugEnabled) {
837 + //error_log('[MXCHAT-DEBUG] ' . $message);
838 + }
839 + };
840 +
841 + // Direct targeting for Gerow theme posts
842 + $post_text = $xpath->query('//div[contains(@class, "post-text")]');
843 + if ($post_text && $post_text->length > 0) {
844 + $debug("Found post-text directly");
845 + $content = '';
846 + foreach ($post_text as $node) {
847 + $content .= $dom->saveHTML($node);
848 + }
849 + if (!empty($content)) {
850 + $debug("Returning post-text content");
851 + return $content;
852 + }
853 + }
854 +
855 + // Try to get the blog details content which contains the post-text
856 + $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
857 + if ($blog_details && $blog_details->length > 0) {
858 + $debug("Found blog-details-content");
859 + $content = '';
860 + foreach ($blog_details as $node) {
861 + $content .= $dom->saveHTML($node);
862 + }
863 + if (!empty($content)) {
864 + $debug("Returning blog-details-content");
865 + return $content;
866 + }
867 + }
868 +
869 + // Try to get the article which contains the blog details
870 + $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
871 + if ($article && $article->length > 0) {
872 + $debug("Found article with blog-details-wrap");
873 + $content = '';
874 + foreach ($article as $node) {
875 + $content .= $dom->saveHTML($node);
876 + }
877 + if (!empty($content)) {
878 + $debug("Returning article content");
879 + return $content;
880 + }
881 + }
882 +
883 + // Try even broader with the blog-item-wrap
884 + $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
885 + if ($blog_item && $blog_item->length > 0) {
886 + $debug("Found blog-item-wrap");
887 + $content = '';
888 + foreach ($blog_item as $node) {
889 + $content .= $dom->saveHTML($node);
890 + }
891 + if (!empty($content)) {
892 + $debug("Returning blog-item-wrap content");
893 + return $content;
894 + }
895 + }
896 +
897 + // Specific Gerow theme path
898 + $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
899 + if ($gerow_path && $gerow_path->length > 0) {
900 + $debug("Found Gerow theme path to post-text");
901 + $content = '';
902 + foreach ($gerow_path as $node) {
903 + $content .= $dom->saveHTML($node);
904 + }
905 + if (!empty($content)) {
906 + $debug("Returning Gerow post-text content");
907 + return $content;
908 + }
909 + }
910 +
911 + // Generic blog post selectors
912 + $selectors = [
913 + // Blog post specific selectors
914 + '//div[contains(@class, "post-text")]',
915 + '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
916 + '//div[contains(@class, "blog-details-content")]',
917 + '//article[contains(@class, "blog-details-wrap")]',
918 + '//div[contains(@class, "entry-content")]',
919 + '//div[contains(@class, "blog-content")]',
920 + '//div[contains(@class, "blog-item-wrap")]',
921 +
922 + // More general content selectors
923 + '//div[contains(@class, "page__content")]',
924 + '//div[contains(@class, "elementor-widget-container")]',
925 + '//div[contains(@class, "elementor-text-editor")]',
926 + '//div[contains(@class, "elementor-widget-text-editor")]',
927 + '//*[contains(@class, "entry-content")]',
928 + '//*[contains(@class, "post-content")]',
929 + '//*[contains(@class, "article-content")]',
930 + '//*[@id="content"]',
931 + '//*[@id="main-content"]',
932 + '//section[contains(@class, "blog-area")]',
933 + '//article',
934 + '//main',
935 + '//div[contains(@class, "content")]'
936 + ];
937 +
938 + // First handle Elementor content
939 + $debug("Checking for Elementor content");
940 + $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-element")]//div[contains(@class, "elementor-widget-container")]');
941 + if ($elementor_widgets && $elementor_widgets->length > 0) {
942 + $debug("Found Elementor widgets");
943 + $combined_content = '';
944 + foreach ($elementor_widgets as $widget) {
945 + $widget_content = $dom->saveHTML($widget);
946 + if (!empty($widget_content)) {
947 + $combined_content .= $widget_content;
948 + }
949 + }
950 + if (!empty($combined_content)) {
951 + $debug("Returning Elementor content");
952 + return $combined_content;
953 + }
954 + }
955 +
956 + // Try standard selectors one by one
957 + foreach ($selectors as $selector) {
958 + $debug("Trying selector: " . $selector);
959 + $nodes = $xpath->query($selector);
960 + if ($nodes && $nodes->length > 0) {
961 + $debug("Found matches for selector: " . $selector);
962 + $content = '';
963 + foreach ($nodes as $node) {
964 + $content .= $dom->saveHTML($node);
965 + }
966 + if (!empty($content)) {
967 + $debug("Returning content from selector: " . $selector);
968 + return $content;
969 + }
970 + }
971 + }
972 +
973 + // Manual regex fallback for post-text if DOM methods fail
974 + $debug("Trying regex fallback");
975 + if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
976 + $debug("Found post-text via regex");
977 + return '<div class="post-text">' . $matches[1] . '</div>';
978 + }
979 +
980 + // Try to extract the blog section as a whole
981 + $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
982 + if ($blog_section && $blog_section->length > 0) {
983 + $debug("Found blog-area section");
984 + $content = '';
985 + foreach ($blog_section as $node) {
986 + $content .= $dom->saveHTML($node);
987 + }
988 + if (!empty($content)) {
989 + $debug("Returning blog-area section content");
990 + return $content;
991 + }
992 + }
993 +
994 + // Fallback: Return the body content if no specific selector matches
995 + $debug("Using body fallback");
996 + $body = $dom->getElementsByTagName('body');
997 + if ($body->length > 0) {
998 + return $dom->saveHTML($body->item(0));
999 + }
1000 +
1001 + // Last resort: return the original HTML
1002 + $debug("Returning original HTML");
1003 + return $html;
1004 + } catch (Exception $e) {
1005 + //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
1006 + return $html; // Return original HTML if parsing fails
1007 + } finally {
1008 + libxml_clear_errors();
1009 + }
1010 +}
1011 +public function mxchat_get_sitemap_processing_status($sitemap_url) {
1012 + $sitemap_url = esc_url_raw($sitemap_url);
1013 + $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
1014 + $status = get_transient($status_key);
1015 +
1016 + if (!$status || !is_array($status)) {
1017 + return false;
1018 + }
1019 +
1020 + // Auto-complete check: if all URLs are processed but status isn't complete
1021 + if (isset($status['processed_urls']) && isset($status['total_urls']) &&
1022 + $status['processed_urls'] >= $status['total_urls'] &&
1023 + isset($status['status']) && $status['status'] !== 'complete' &&
1024 + $status['status'] !== 'error') {
1025 +
1026 + // Mark as complete
1027 + $status['status'] = 'complete';
1028 + $status['processed_urls'] = $status['total_urls']; // Ensure exact match
1029 +
1030 + // Update the transient with the corrected status
1031 + set_transient($status_key, $status, DAY_IN_SECONDS);
1032 + }
1033 +
1034 + return array(
1035 + 'total_urls' => absint($status['total_urls']),
1036 + 'processed_urls' => absint($status['processed_urls']),
1037 + 'failed_urls' => absint($status['failed_urls'] ?? 0),
1038 + 'percentage' => ($status['total_urls'] > 0)
1039 + ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
1040 + : 0,
1041 + 'status' => sanitize_text_field($status['status']),
1042 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1043 + 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
1044 + 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
1045 + 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
1046 + );
1047 +}
1048 +
1049 +public function mxchat_ajax_get_status_updates() {
1050 + try {
1051 + // Verify the request
1052 + check_ajax_referer('mxchat_status_nonce', 'nonce');
1053 +
1054 + // Get active queue IDs
1055 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1056 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1057 +
1058 + $sitemap_status = false;
1059 + $pdf_status = false;
1060 +
1061 + // Get sitemap queue status
1062 + if ($sitemap_queue_id) {
1063 + $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1064 + }
1065 +
1066 + // Get PDF queue status
1067 + if ($pdf_queue_id) {
1068 + $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1069 + }
1070 +
1071 + $is_active_processing =
1072 + ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1073 + ($pdf_status && $pdf_status['status'] === 'processing');
1074 +
1075 + // Return JSON response with the status data
1076 + wp_send_json(array(
1077 + 'pdf_status' => $pdf_status,
1078 + 'sitemap_status' => $sitemap_status,
1079 + 'is_processing' => $is_active_processing,
1080 + 'sitemap_queue_id' => $sitemap_queue_id,
1081 + 'pdf_queue_id' => $pdf_queue_id
1082 + ));
1083 +
1084 + } catch (Exception $e) {
1085 + error_log('MxChat Status Update Error: ' . $e->getMessage());
1086 +
1087 + wp_send_json_error(array(
1088 + 'message' => 'Error getting status updates: ' . $e->getMessage(),
1089 + 'status' => 'error'
1090 + ));
1091 + }
1092 +}
1093 +
1094 +/**
1095 + * Helper function to get queue status data
1096 + */
1097 +private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
1098 + global $wpdb;
1099 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
1100 +
1101 + // Get counts by status
1102 + $counts = $wpdb->get_results($wpdb->prepare(
1103 + "SELECT status, COUNT(*) as count
1104 + FROM $table_name
1105 + WHERE queue_id = %s
1106 + GROUP BY status",
1107 + $queue_id
1108 + ), OBJECT_K);
1109 +
1110 + $total = 0;
1111 + $completed = 0;
1112 + $failed = 0;
1113 + $processing = 0;
1114 + $pending = 0;
1115 +
1116 + foreach ($counts as $status => $data) {
1117 + $count = absint($data->count);
1118 + $total += $count;
1119 +
1120 + switch ($status) {
1121 + case 'completed':
1122 + $completed = $count;
1123 + break;
1124 + case 'failed':
1125 + $failed = $count;
1126 + break;
1127 + case 'processing':
1128 + $processing = $count;
1129 + break;
1130 + case 'pending':
1131 + $pending = $count;
1132 + break;
1133 + }
1134 + }
1135 +
1136 + if ($total === 0) {
1137 + return false;
1138 + }
1139 +
1140 + // Calculate percentage
1141 + $percentage = round((($completed + $failed) / $total) * 100);
1142 +
1143 + // Get failed items details (limit to 50)
1144 + $failed_items = array();
1145 + if ($failed > 0) {
1146 + $failed_results = $wpdb->get_results($wpdb->prepare(
1147 + "SELECT item_type, item_data, error_message, attempts, completed_at
1148 + FROM $table_name
1149 + WHERE queue_id = %s
1150 + AND status = 'failed'
1151 + AND attempts >= max_attempts
1152 + ORDER BY id DESC
1153 + LIMIT 50",
1154 + $queue_id
1155 + ));
1156 +
1157 + foreach ($failed_results as $item) {
1158 + $data = json_decode($item->item_data, true);
1159 + $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
1160 +
1161 + $failed_items[] = array(
1162 + 'url' => $url,
1163 + 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
1164 + 'error' => $item->error_message,
1165 + 'retries' => $item->attempts,
1166 + 'time' => strtotime($item->completed_at)
1167 + );
1168 + }
1169 + }
1170 +
1171 + // Get queue metadata
1172 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
1173 +
1174 + // Determine if queue is complete
1175 + $is_complete = ($pending === 0 && $processing === 0);
1176 +
1177 + // Get last update time
1178 + $last_update = $wpdb->get_var($wpdb->prepare(
1179 + "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
1180 + FROM $table_name
1181 + WHERE queue_id = %s",
1182 + $queue_id
1183 + ));
1184 +
1185 + $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
1186 +
1187 + // Format based on type
1188 + if ($type === 'pdf') {
1189 + return array(
1190 + 'total_pages' => $total,
1191 + 'processed_pages' => $completed + $failed,
1192 + 'failed_pages' => $failed,
1193 + 'percentage' => $percentage,
1194 + 'status' => $is_complete ? 'complete' : 'processing',
1195 + 'last_update' => $last_update_text,
1196 + 'failed_pages_list' => $failed_items,
1197 + 'pdf_url' => $source_url,
1198 + 'queue_id' => $queue_id
1199 + );
1200 + } else {
1201 + return array(
1202 + 'total_urls' => $total,
1203 + 'processed_urls' => $completed + $failed,
1204 + 'failed_urls' => $failed,
1205 + 'percentage' => $percentage,
1206 + 'status' => $is_complete ? 'complete' : 'processing',
1207 + 'last_update' => $last_update_text,
1208 + 'failed_urls_list' => $failed_items,
1209 + 'sitemap_url' => $source_url,
1210 + 'queue_id' => $queue_id
1211 + );
1212 + }
1213 +}
1214 +
1215 +public function mxchat_stop_processing() {
1216 + // Verify permissions
1217 + if (!current_user_can('manage_options')) {
1218 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
1219 + }
1220 +
1221 + // Verify nonce
1222 + check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
1223 +
1224 + global $wpdb;
1225 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
1226 +
1227 + // Get active queue IDs
1228 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1229 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1230 +
1231 + // Delete all pending items from active queues
1232 + if ($sitemap_queue_id) {
1233 + $wpdb->delete(
1234 + $table_name,
1235 + array(
1236 + 'queue_id' => $sitemap_queue_id,
1237 + 'status' => 'pending'
1238 + ),
1239 + array('%s', '%s')
1240 + );
1241 +
1242 + delete_transient('mxchat_active_queue_sitemap');
1243 + delete_transient('mxchat_last_sitemap_url');
1244 + }
1245 +
1246 + if ($pdf_queue_id) {
1247 + // Get PDF path before deleting
1248 + $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
1249 +
1250 + $wpdb->delete(
1251 + $table_name,
1252 + array(
1253 + 'queue_id' => $pdf_queue_id,
1254 + 'status' => 'pending'
1255 + ),
1256 + array('%s', '%s')
1257 + );
1258 +
1259 + // Delete PDF file
1260 + if ($pdf_path && file_exists($pdf_path)) {
1261 + wp_delete_file($pdf_path);
1262 + }
1263 +
1264 + delete_transient('mxchat_active_queue_pdf');
1265 + delete_transient('mxchat_last_pdf_url');
1266 + }
1267 +
1268 + // Redirect back with a success message
1269 + set_transient('mxchat_admin_notice_success',
1270 + esc_html__('Processing has been stopped successfully.', 'mxchat'),
1271 + 30
1272 + );
1273 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
1274 + exit;
1275 +}
1276 +
1277 +/**
1278 + * Get content list for processing
1279 + */
1280 +public function ajax_mxchat_get_content_list() {
1281 + // Verify the nonce
1282 + check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
1283 +
1284 + if (!current_user_can('manage_options')) {
1285 + wp_send_json_error(__('Unauthorized access', 'mxchat'));
1286 + }
1287 +
1288 + $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
1289 + $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 50;
1290 + $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
1291 + $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
1292 + $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
1293 + $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
1294 +
1295 + // Build query args
1296 + $args = array(
1297 + 'posts_per_page' => $per_page,
1298 + 'paged' => $page,
1299 + 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
1300 + 'orderby' => 'date',
1301 + 'order' => 'DESC',
1302 + );
1303 +
1304 + // Handle post types - IMPROVED VERSION
1305 + if ($post_type !== 'all') {
1306 + $args['post_type'] = $post_type;
1307 + } else {
1308 + // Get all available post types that might contain content
1309 + $all_post_types = array();
1310 +
1311 + // First get all public post types
1312 + $public_types = get_post_types(array('public' => true), 'names');
1313 + $all_post_types = array_merge($all_post_types, $public_types);
1314 +
1315 + // Add common forum/community post types
1316 + $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
1317 + foreach ($forum_types as $forum_type) {
1318 + if (post_type_exists($forum_type)) {
1319 + $all_post_types[] = $forum_type;
1320 + }
1321 + }
1322 +
1323 + // Add other commonly used post types
1324 + $common_types = array('product', 'job_listing', 'event', 'portfolio');
1325 + foreach ($common_types as $common_type) {
1326 + if (post_type_exists($common_type)) {
1327 + $all_post_types[] = $common_type;
1328 + }
1329 + }
1330 +
1331 + // Remove duplicates and ensure we have at least some post types
1332 + $all_post_types = array_unique($all_post_types);
1333 +
1334 + if (empty($all_post_types)) {
1335 + // Fallback to basic post types
1336 + $all_post_types = array('post', 'page');
1337 + }
1338 +
1339 + $args['post_type'] = $all_post_types;
1340 +
1341 + // Debug logging to see what post types are being queried
1342 + //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
1343 + }
1344 +
1345 + if (!empty($search)) {
1346 + $args['s'] = $search;
1347 + }
1348 +
1349 + // Get processed data from storage
1350 + $processed_data = array();
1351 +
1352 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
1353 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1354 +
1355 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
1356 + // Get fresh data from Pinecone - no caching
1357 + $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
1358 + } else {
1359 + // WordPress DB checking with better URL matching for all post types
1360 + global $wpdb;
1361 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1362 + $processed_items = $wpdb->get_results("SELECT id, source_url, timestamp FROM {$table_name}");
1363 +
1364 + if (!empty($processed_items)) {
1365 + foreach ($processed_items as $item) {
1366 + // Use improved URL matching that works for all post types
1367 + $post_id = $this->mxchat_url_to_post_id_improved($item->source_url);
1368 +
1369 + if ($post_id) {
1370 + $processed_data[$post_id] = array(
1371 + 'db_id' => $item->id,
1372 + 'timestamp' => $item->timestamp,
1373 + 'url' => $item->source_url,
1374 + 'source' => 'wordpress'
1375 + );
1376 + }
1377 + }
1378 + }
1379 + }
1380 +
1381 + // Get processed IDs as a simple array for in_array checks
1382 + $processed_ids = array_keys($processed_data);
1383 +
1384 + // Handle processed/unprocessed filter
1385 + if ($processed_filter === 'processed' && !empty($processed_ids)) {
1386 + $args['post__in'] = $processed_ids;
1387 + } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
1388 + $args['post__not_in'] = $processed_ids;
1389 + }
1390 +
1391 + // Run the query
1392 + $query = new WP_Query($args);
1393 + $content_items = array();
1394 +
1395 + if ($query->have_posts()) {
1396 + while ($query->have_posts()) {
1397 + $query->the_post();
1398 + $id = get_the_ID();
1399 + $post_date = get_the_date();
1400 + $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
1401 + $word_count = str_word_count(strip_tags(get_the_content()));
1402 +
1403 + $is_processed = in_array($id, $processed_ids);
1404 + $processed_date = '';
1405 + $db_record_id = 0;
1406 + $data_source = 'none';
1407 +
1408 + if ($is_processed && isset($processed_data[$id])) {
1409 + $item_data = $processed_data[$id];
1410 + $data_source = $item_data['source'];
1411 +
1412 + if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
1413 + // WordPress DB format
1414 + $timestamp = strtotime($item_data['timestamp']);
1415 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
1416 + $db_record_id = $item_data['db_id'];
1417 + } elseif ($data_source === 'pinecone') {
1418 + // Pinecone format
1419 + $processed_date = $item_data['processed_date'];
1420 + $db_record_id = $item_data['db_id'];
1421 + }
1422 + }
1423 +
1424 + $content_items[] = array(
1425 + 'id' => $id,
1426 + 'title' => get_the_title(),
1427 + 'permalink' => get_permalink(),
1428 + 'date' => $post_date,
1429 + 'type' => get_post_type(),
1430 + 'status' => get_post_status(),
1431 + 'excerpt' => $excerpt,
1432 + 'word_count' => $word_count,
1433 + 'already_processed' => $is_processed,
1434 + 'processed_date' => $processed_date,
1435 + 'db_record_id' => $db_record_id,
1436 + 'data_source' => $data_source
1437 + );
1438 + }
1439 + wp_reset_postdata();
1440 + }
1441 +
1442 + $response = array(
1443 + 'items' => $content_items,
1444 + 'total' => $query->found_posts,
1445 + 'total_pages' => $query->max_num_pages,
1446 + 'current_page' => $page,
1447 + 'processed_count' => count($processed_ids)
1448 + );
1449 +
1450 + wp_send_json_success($response);
1451 + exit;
1452 +}
1453 +
1454 +
1455 +/**
1456 + * This function handles various WooCommerce URL formats and permalink structures
1457 + */
1458 +private function mxchat_url_to_post_id_improved($url) {
1459 + // First try the standard WordPress function
1460 + $post_id = url_to_postid($url);
1461 +
1462 + if ($post_id > 0) {
1463 + return $post_id;
1464 + }
1465 +
1466 + // If that fails, try more aggressive URL matching
1467 + // Remove trailing slashes and query parameters for better matching
1468 + $clean_url = rtrim($url, '/');
1469 + $clean_url = strtok($clean_url, '?'); // Remove query parameters
1470 +
1471 + // Try again with cleaned URL
1472 + $post_id = url_to_postid($clean_url);
1473 + if ($post_id > 0) {
1474 + return $post_id;
1475 + }
1476 +
1477 + // For bbPress forum topics, try extracting slug from URL
1478 + if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
1479 + // Handle bbPress URLs: /forums/topic/topic-name/
1480 + if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
1481 + $topic_slug = $matches[1];
1482 +
1483 + // Look up topic by slug
1484 + $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
1485 + if ($topic) {
1486 + return $topic->ID;
1487 + }
1488 +
1489 + // Alternative method: query by post_name
1490 + global $wpdb;
1491 + $post_id = $wpdb->get_var($wpdb->prepare(
1492 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
1493 + $topic_slug
1494 + ));
1495 +
1496 + if ($post_id) {
1497 + return intval($post_id);
1498 + }
1499 + }
1500 +
1501 + // Handle simpler topic URLs: /topic/topic-name/
1502 + if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
1503 + $topic_slug = $matches[1];
1504 +
1505 + global $wpdb;
1506 + $post_id = $wpdb->get_var($wpdb->prepare(
1507 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
1508 + $topic_slug
1509 + ));
1510 +
1511 + if ($post_id) {
1512 + return intval($post_id);
1513 + }
1514 + }
1515 + }
1516 +
1517 + // For WooCommerce products
1518 + if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
1519 + // Extract product slug from various URL formats
1520 + $product_slug = '';
1521 +
1522 + // Handle pretty permalinks: /product/product-name/
1523 + if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
1524 + $product_slug = $matches[1];
1525 + }
1526 + // Handle query parameters: ?product=product-name
1527 + elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
1528 + $product_slug = $matches[1];
1529 + }
1530 +
1531 + if (!empty($product_slug)) {
1532 + // Look up product by slug
1533 + $product = get_page_by_path($product_slug, OBJECT, 'product');
1534 + if ($product) {
1535 + return $product->ID;
1536 + }
1537 +
1538 + // Alternative method: query by post_name
1539 + global $wpdb;
1540 + $post_id = $wpdb->get_var($wpdb->prepare(
1541 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
1542 + $product_slug
1543 + ));
1544 +
1545 + if ($post_id) {
1546 + return intval($post_id);
1547 + }
1548 + }
1549 + }
1550 +
1551 + // Generic approach: try to extract slug and match against all post types
1552 + $parsed_url = wp_parse_url($clean_url);
1553 + $path = $parsed_url['path'] ?? '';
1554 +
1555 + if (!empty($path)) {
1556 + // Get the last part of the path as potential slug
1557 + $path_parts = array_filter(explode('/', trim($path, '/')));
1558 + $potential_slug = end($path_parts);
1559 +
1560 + if (!empty($potential_slug)) {
1561 + global $wpdb;
1562 +
1563 + // Try to find any post with this slug
1564 + $post_id = $wpdb->get_var($wpdb->prepare(
1565 + "SELECT ID FROM {$wpdb->posts}
1566 + WHERE post_name = %s
1567 + AND post_status IN ('publish', 'closed', 'private')
1568 + AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
1569 + ORDER BY CASE
1570 + WHEN post_type = 'post' THEN 1
1571 + WHEN post_type = 'page' THEN 2
1572 + WHEN post_type = 'topic' THEN 3
1573 + WHEN post_type = 'product' THEN 4
1574 + ELSE 5
1575 + END
1576 + LIMIT 1",
1577 + $potential_slug
1578 + ));
1579 +
1580 + if ($post_id) {
1581 + return intval($post_id);
1582 + }
1583 + }
1584 + }
1585 +
1586 + // ADDITIONAL: Try direct database lookup by URL variations
1587 + global $wpdb;
1588 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1589 +
1590 + // Try variations of the URL (with/without trailing slash, http/https)
1591 + $url_variations = array(
1592 + $url,
1593 + rtrim($url, '/'),
1594 + $url . '/',
1595 + str_replace('http://', 'https://', $url),
1596 + str_replace('https://', 'http://', $url),
1597 + str_replace('http://', 'https://', rtrim($url, '/')),
1598 + str_replace('https://', 'http://', rtrim($url, '/'))
1599 + );
1600 +
1601 + // Remove duplicates
1602 + $url_variations = array_unique($url_variations);
1603 +
1604 + foreach ($url_variations as $variation) {
1605 + $existing_record = $wpdb->get_row($wpdb->prepare(
1606 + "SELECT id, source_url FROM $table_name WHERE source_url = %s",
1607 + $variation
1608 + ));
1609 +
1610 + if ($existing_record) {
1611 + // Try to get post ID from this stored URL
1612 + $stored_post_id = url_to_postid($existing_record->source_url);
1613 + if ($stored_post_id > 0) {
1614 + return $stored_post_id;
1615 + }
1616 + }
1617 + }
1618 +
1619 + return 0; // No match found
1620 +}
1621 +/**
1622 + * Process selected content via AJAX
1623 + */
1624 +public function ajax_mxchat_process_selected_content() {
1625 + // Basic request validation
1626 + if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
1627 + wp_send_json_error('Invalid nonce');
1628 + exit;
1629 + }
1630 +
1631 + if (!current_user_can('manage_options')) {
1632 + wp_send_json_error('Unauthorized access');
1633 + exit;
1634 + }
1635 +
1636 + // Get post IDs - safely parse the array
1637 + $post_ids = array();
1638 + if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
1639 + foreach ($_POST['post_ids'] as $id) {
1640 + $post_ids[] = absint($id);
1641 + }
1642 + }
1643 +
1644 + if (empty($post_ids)) {
1645 + wp_send_json_error('No content selected');
1646 + exit;
1647 + }
1648 +
1649 + // Get bot_id from request
1650 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1651 +
1652 + // Process only ONE post at a time to avoid request size issues
1653 + $post_id = reset($post_ids);
1654 + $post = get_post($post_id);
1655 +
1656 + if (!$post) {
1657 + wp_send_json_error('Post not found');
1658 + exit;
1659 + }
1660 +
1661 + // Get content including ACF fields
1662 + $content = $post->post_title . "\n\n" . wp_strip_all_tags($post->post_content);
1663 +
1664 + // ADD ACF FIELDS SUPPORT
1665 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
1666 + if (!empty($acf_fields)) {
1667 + $acf_content_parts = array();
1668 +
1669 + foreach ($acf_fields as $field_name => $field_value) {
1670 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
1671 +
1672 + if (!empty($formatted_value)) {
1673 + $field_label = ucwords(str_replace('_', ' ', $field_name));
1674 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
1675 + }
1676 + }
1677 +
1678 + if (!empty($acf_content_parts)) {
1679 + $content .= "\n\n" . implode("\n", $acf_content_parts);
1680 + }
1681 + }
1682 +
1683 + $content = substr($content, 0, 10000); // Limit content size
1684 +
1685 + // Get bot-specific API key
1686 + $bot_options = $this->get_bot_options($bot_id);
1687 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1688 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
1689 +
1690 + if (strpos($selected_model, 'voyage') === 0) {
1691 + $api_key = $options['voyage_api_key'] ?? '';
1692 + $provider_name = 'Voyage AI';
1693 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
1694 + $api_key = $options['gemini_api_key'] ?? '';
1695 + $provider_name = 'Google Gemini';
1696 + } else {
1697 + $api_key = $options['api_key'] ?? '';
1698 + $provider_name = 'OpenAI';
1699 + }
1700 +
1701 + if (empty($api_key)) {
1702 + wp_send_json_error($provider_name . ' API key not configured');
1703 + exit;
1704 + }
1705 +
1706 + $source_url = get_permalink($post_id);
1707 + $vector_id = md5($source_url); // Vector ID for Pinecone
1708 +
1709 + // Check for existing content in bot-specific storage
1710 + $is_update = false;
1711 +
1712 + // Get bot-specific Pinecone configuration
1713 + $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1714 + $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
1715 +
1716 + if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
1717 + // Check Pinecone for this bot
1718 + $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
1719 + if (isset($pinecone_data[$post_id])) {
1720 + $is_update = true;
1721 + }
1722 + } else {
1723 + // Check WordPress DB (same as before since it's shared)
1724 + global $wpdb;
1725 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1726 + $existing_record = $wpdb->get_row($wpdb->prepare(
1727 + "SELECT id FROM $table_name WHERE source_url = %s",
1728 + $source_url
1729 + ));
1730 +
1731 + if ($existing_record) {
1732 + $is_update = true;
1733 + }
1734 + }
1735 +
1736 + // Use the centralized utility function with bot_id
1737 + $result = MxChat_Utils::submit_content_to_db(
1738 + $content,
1739 + $source_url,
1740 + $api_key,
1741 + $vector_id,
1742 + $bot_id
1743 + );
1744 +
1745 + if (is_wp_error($result)) {
1746 + wp_send_json_error('Storage failed: ' . $result->get_error_message());
1747 + exit;
1748 + }
1749 +
1750 + // Automatically apply role restriction based on tags
1751 + $this->apply_role_restriction_to_post($post_id, $source_url);
1752 +
1753 + $operation_type = $is_update ? 'update' : 'new';
1754 +
1755 + // Count ACF fields for debugging
1756 + $acf_field_count = count($acf_fields);
1757 +
1758 + // Success response with minimal data
1759 + wp_send_json_success(array(
1760 + 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
1761 + 'post_id' => $post_id,
1762 + 'title' => $post->post_title,
1763 + 'operation_type' => $operation_type,
1764 + 'vector_id' => $vector_id,
1765 + 'acf_fields_found' => $acf_field_count,
1766 + 'content_preview' => substr($content, 0, 100) . '...',
1767 + 'bot_id' => $bot_id
1768 + ));
1769 + exit;
1770 +}
1771 +
1772 +private function apply_role_restriction_to_post($post_id, $source_url) {
1773 + // Get tag-role mappings
1774 + $mappings = get_option('mxchat_tag_role_mappings', array());
1775 +
1776 + if (empty($mappings)) {
1777 + return; // No mappings, leave as public
1778 + }
1779 +
1780 + // Get all tags for the post
1781 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
1782 +
1783 + if (empty($post_tags)) {
1784 + return; // No tags, leave as public
1785 + }
1786 +
1787 + // Determine the highest role restriction based on tags
1788 + $highest_role = 'public';
1789 + $role_hierarchy = array(
1790 + 'public' => 0,
1791 + 'logged_in' => 1,
1792 + 'subscriber' => 2,
1793 + 'contributor' => 3,
1794 + 'author' => 4,
1795 + 'editor' => 5,
1796 + 'administrator' => 6
1797 + );
1798 +
1799 + foreach ($post_tags as $tag_slug) {
1800 + if (isset($mappings[$tag_slug])) {
1801 + $role = $mappings[$tag_slug];
1802 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
1803 + $highest_role = $role;
1804 + }
1805 + }
1806 + }
1807 +
1808 + // If no restricted tags found, return (leave as public)
1809 + if ($highest_role === 'public') {
1810 + return;
1811 + }
1812 +
1813 + // Update the role restriction in the database
1814 + global $wpdb;
1815 +
1816 + // Check if using Pinecone
1817 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
1818 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1819 +
1820 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
1821 + // Update Pinecone role restriction
1822 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
1823 + $vector_id = md5($source_url);
1824 +
1825 + $wpdb->replace(
1826 + $roles_table,
1827 + array(
1828 + 'vector_id' => $vector_id,
1829 + 'role_restriction' => $highest_role,
1830 + 'updated_at' => current_time('mysql')
1831 + ),
1832 + array('%s', '%s', '%s')
1833 + );
1834 + } else {
1835 + // Update WordPress DB
1836 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1837 +
1838 + $wpdb->update(
1839 + $table_name,
1840 + array('role_restriction' => $highest_role),
1841 + array('source_url' => $source_url),
1842 + array('%s'),
1843 + array('%s')
1844 + );
1845 + }
1846 +}
1847 +
1848 +public function mxchat_get_public_post_types() {
1849 + // Get all public post types
1850 + $post_types = get_post_types(array('public' => true), 'objects');
1851 + $post_type_options = array();
1852 +
1853 + foreach ($post_types as $post_type) {
1854 + $post_type_options[$post_type->name] = $post_type->label;
1855 + }
1856 +
1857 + // Also include common forum/community post types that might not be marked as public
1858 + $additional_types = array(
1859 + 'topic' => 'Forum Topics (bbPress)',
1860 + 'reply' => 'Forum Replies (bbPress)',
1861 + 'forum' => 'Forums (bbPress)',
1862 + 'wpforo_topic' => 'wpForo Topics',
1863 + 'wpforo_post' => 'wpForo Posts'
1864 + );
1865 +
1866 + foreach ($additional_types as $type_name => $type_label) {
1867 + if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
1868 + $post_type_options[$type_name] = $type_label;
1869 + }
1870 + }
1871 +
1872 + return $post_type_options;
1873 +}
1874 +
1875 +/**
1876 + * Retrieves processed content from Pinecone API
1877 + */
1878 +public function mxchat_get_pinecone_processed_content($pinecone_options) {
1879 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1880 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1881 +
1882 + if (empty($api_key) || empty($host)) {
1883 + return array();
1884 + }
1885 +
1886 + $pinecone_data = array();
1887 +
1888 + try {
1889 + // Always get fresh data from Pinecone
1890 + $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
1891 +
1892 + // Method 2: Final fallback - try stats endpoint (if available)
1893 + if (empty($pinecone_data)) {
1894 + $stats_url = "https://{$host}/describe_index_stats";
1895 +
1896 + $response = wp_remote_post($stats_url, array(
1897 + 'headers' => array(
1898 + 'Api-Key' => $api_key,
1899 + 'Content-Type' => 'application/json'
1900 + ),
1901 + 'body' => json_encode(array()),
1902 + 'timeout' => 30
1903 + ));
1904 +
1905 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1906 + $body = wp_remote_retrieve_body($response);
1907 + $stats_data = json_decode($body, true);
1908 + }
1909 + }
1910 +
1911 + } catch (Exception $e) {
1912 + // Log error but return fresh data only
1913 + }
1914 +
1915 + return $pinecone_data;
1916 +}
1917 +public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
1918 + //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
1919 +
1920 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1921 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1922 +
1923 + if (empty($api_key) || empty($host) || empty($vector_ids)) {
1924 + //error_log('DEBUG: Missing parameters for fetch by IDs');
1925 + return array();
1926 + }
1927 +
1928 + try {
1929 + $fetch_url = "https://{$host}/vectors/fetch";
1930 + //error_log('DEBUG: Fetch URL: ' . $fetch_url);
1931 + //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
1932 +
1933 + // Pinecone fetch API allows fetching specific vectors by ID
1934 + $fetch_data = array(
1935 + 'ids' => array_values($vector_ids)
1936 + );
1937 +
1938 + $response = wp_remote_post($fetch_url, array(
1939 + 'headers' => array(
1940 + 'Api-Key' => $api_key,
1941 + 'Content-Type' => 'application/json'
1942 + ),
1943 + 'body' => json_encode($fetch_data),
1944 + 'timeout' => 30
1945 + ));
1946 +
1947 + if (is_wp_error($response)) {
1948 + //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
1949 + return array();
1950 + }
1951 +
1952 + $response_code = wp_remote_retrieve_response_code($response);
1953 + //error_log('DEBUG: Fetch response code: ' . $response_code);
1954 +
1955 + if ($response_code !== 200) {
1956 + $error_body = wp_remote_retrieve_body($response);
1957 + //error_log('DEBUG: Fetch failed with body: ' . $error_body);
1958 + return array();
1959 + }
1960 +
1961 + $body = wp_remote_retrieve_body($response);
1962 + $data = json_decode($body, true);
1963 +
1964 + //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
1965 +
1966 + if (!isset($data['vectors'])) {
1967 + //error_log('DEBUG: No vectors key in response');
1968 + return array();
1969 + }
1970 +
1971 + $processed_data = array();
1972 +
1973 + foreach ($data['vectors'] as $vector_id => $vector_data) {
1974 + $metadata = $vector_data['metadata'] ?? array();
1975 + $source_url = $metadata['source_url'] ?? '';
1976 +
1977 + if (!empty($source_url)) {
1978 + $post_id = url_to_postid($source_url);
1979 + if ($post_id) {
1980 + $created_at = $metadata['created_at'] ?? '';
1981 + $processed_date = 'Recently';
1982 +
1983 + if (!empty($created_at)) {
1984 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
1985 + if ($timestamp) {
1986 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
1987 + }
1988 + }
1989 +
1990 + $processed_data[$post_id] = array(
1991 + 'db_id' => $vector_id,
1992 + 'processed_date' => $processed_date,
1993 + 'url' => $source_url,
1994 + 'source' => 'pinecone',
1995 + 'timestamp' => $timestamp ?? current_time('timestamp')
1996 + );
1997 + }
1998 + }
1999 + }
2000 +
2001 + //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
2002 + return $processed_data;
2003 +
2004 + } catch (Exception $e) {
2005 + //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
2006 + return array();
2007 + }
2008 +}
2009 +
2010 +/**
2011 + * Scan Pinecone for processed content
2012 + */
2013 +public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
2014 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2015 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2016 +
2017 + if (empty($api_key) || empty($host)) {
2018 + return array();
2019 + }
2020 +
2021 + try {
2022 + // Use multiple random vectors to get better coverage
2023 + $all_matches = array();
2024 + $seen_ids = array();
2025 +
2026 + // Try 3 different random vectors to get better coverage
2027 + for ($i = 0; $i < 3; $i++) {
2028 + $query_url = "https://{$host}/query";
2029 +
2030 + // Generate a random unit vector instead of zeros
2031 + $random_vector = array();
2032 + for ($j = 0; $j < 1536; $j++) {
2033 + $random_vector[] = (rand(-1000, 1000) / 1000.0);
2034 + }
2035 +
2036 + // Normalize the vector to unit length
2037 + $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
2038 + if ($magnitude > 0) {
2039 + $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
2040 + }
2041 +
2042 + $query_data = array(
2043 + 'includeMetadata' => true,
2044 + 'includeValues' => false,
2045 + 'topK' => 10000,
2046 + 'vector' => $random_vector
2047 + );
2048 +
2049 + $response = wp_remote_post($query_url, array(
2050 + 'headers' => array(
2051 + 'Api-Key' => $api_key,
2052 + 'Content-Type' => 'application/json'
2053 + ),
2054 + 'body' => json_encode($query_data),
2055 + 'timeout' => 30
2056 + ));
2057 +
2058 + if (is_wp_error($response)) {
2059 + continue;
2060 + }
2061 +
2062 + $response_code = wp_remote_retrieve_response_code($response);
2063 +
2064 + if ($response_code !== 200) {
2065 + continue;
2066 + }
2067 +
2068 + $body = wp_remote_retrieve_body($response);
2069 + $data = json_decode($body, true);
2070 +
2071 + if (isset($data['matches'])) {
2072 + foreach ($data['matches'] as $match) {
2073 + $match_id = $match['id'] ?? '';
2074 + if (!empty($match_id) && !isset($seen_ids[$match_id])) {
2075 + $all_matches[] = $match;
2076 + $seen_ids[$match_id] = true;
2077 + }
2078 + }
2079 + }
2080 + }
2081 +
2082 + // Convert matches to processed data format
2083 + $processed_data = array();
2084 +
2085 + foreach ($all_matches as $match) {
2086 + $metadata = $match['metadata'] ?? array();
2087 + $source_url = $metadata['source_url'] ?? '';
2088 + $match_id = $match['id'] ?? '';
2089 +
2090 + if (!empty($source_url) && !empty($match_id)) {
2091 + $post_id = url_to_postid($source_url);
2092 + if ($post_id) {
2093 + $created_at = $metadata['created_at'] ?? '';
2094 + $processed_date = 'Recently';
2095 +
2096 + if (!empty($created_at)) {
2097 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
2098 + if ($timestamp) {
2099 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
2100 + }
2101 + }
2102 +
2103 + $processed_data[$post_id] = array(
2104 + 'db_id' => $match_id,
2105 + 'processed_date' => $processed_date,
2106 + 'url' => $source_url,
2107 + 'source' => 'pinecone',
2108 + 'timestamp' => $timestamp ?? current_time('timestamp')
2109 + );
2110 + }
2111 + }
2112 + }
2113 +
2114 + return $processed_data;
2115 +
2116 + } catch (Exception $e) {
2117 + return array();
2118 + }
2119 +}
2120 +/**
2121 + * Generate embeddings from input text for MXChat with bot support
2122 + */
2123 +private function mxchat_generate_embedding($text, $bot_id = 'default') {
2124 + // Enable detailed logging for debugging
2125 + //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
2126 + //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
2127 +
2128 + // Get bot-specific options
2129 + $bot_options = $this->get_bot_options($bot_id);
2130 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
2131 +
2132 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
2133 + //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
2134 +
2135 + // Determine provider and endpoint
2136 + if (strpos($selected_model, 'voyage') === 0) {
2137 + $api_key = $options['voyage_api_key'] ?? '';
2138 + $endpoint = 'https://api.voyageai.com/v1/embeddings';
2139 + $provider_name = 'Voyage AI';
2140 + //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
2141 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
2142 + $api_key = $options['gemini_api_key'] ?? '';
2143 + $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
2144 + $provider_name = 'Google Gemini';
2145 + //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
2146 + } else {
2147 + $api_key = $options['api_key'] ?? '';
2148 + $endpoint = 'https://api.openai.com/v1/embeddings';
2149 + $provider_name = 'OpenAI';
2150 + //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
2151 + }
2152 +
2153 + //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
2154 +
2155 + if (empty($api_key)) {
2156 + $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
2157 + //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
2158 + return $error_message;
2159 + }
2160 +
2161 + // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
2162 + $estimated_tokens = ceil(str_word_count($text) / 0.75);
2163 + //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
2164 +
2165 + if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
2166 + //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
2167 + // Consider truncating text here
2168 + }
2169 +
2170 + // Prepare request body based on provider
2171 + if (strpos($selected_model, 'gemini-embedding') === 0) {
2172 + // Gemini API format
2173 + $request_body = array(
2174 + 'model' => 'models/' . $selected_model,
2175 + 'content' => array(
2176 + 'parts' => array(
2177 + array('text' => $text)
2178 + )
2179 + )
2180 + );
2181 +
2182 + // Set output dimensionality to 1536 for consistency with other models
2183 + $request_body['outputDimensionality'] = 1536;
2184 + } else {
2185 + // OpenAI/Voyage API format
2186 + $request_body = array(
2187 + 'model' => $selected_model,
2188 + 'input' => $text
2189 + );
2190 +
2191 + // Add output_dimension for voyage-3-large model
2192 + if ($selected_model === 'voyage-3-large') {
2193 + $request_body['output_dimension'] = 2048;
2194 + }
2195 + }
2196 +
2197 + //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
2198 +
2199 + // Prepare headers based on provider
2200 + if (strpos($selected_model, 'gemini-embedding') === 0) {
2201 + // Gemini uses API key as query parameter
2202 + $endpoint .= '?key=' . $api_key;
2203 + $headers = array(
2204 + 'Content-Type' => 'application/json'
2205 + );
2206 + } else {
2207 + // OpenAI/Voyage use Bearer token
2208 + $headers = array(
2209 + 'Authorization' => 'Bearer ' . $api_key,
2210 + 'Content-Type' => 'application/json'
2211 + );
2212 + }
2213 +
2214 + // Make API request
2215 + //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
2216 + $response = wp_remote_post($endpoint, array(
2217 + 'body' => wp_json_encode($request_body),
2218 + 'headers' => $headers,
2219 + 'timeout' => 60 // Increased timeout for large inputs
2220 + ));
2221 +
2222 + // Handle wp_remote_post errors
2223 + if (is_wp_error($response)) {
2224 + $error_message = $response->get_error_message();
2225 + //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
2226 + return 'Connection error: ' . $error_message;
2227 + }
2228 +
2229 + // Get and check HTTP response code
2230 + $http_code = wp_remote_retrieve_response_code($response);
2231 + //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
2232 +
2233 + if ($http_code !== 200) {
2234 + $error_body = wp_remote_retrieve_body($response);
2235 + //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
2236 +
2237 + // Try to parse error for more details
2238 + $error_json = json_decode($error_body, true);
2239 + if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
2240 + $error_type = $error_json['error']['type'] ?? 'unknown';
2241 + $error_message = $error_json['error']['message'] ?? 'No message';
2242 + //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
2243 + //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
2244 +
2245 + // Customize error message for common API errors
2246 + if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
2247 + $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
2248 + } elseif ($error_type === 'authentication_error') {
2249 + $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
2250 + }
2251 +
2252 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
2253 + return $error_message;
2254 + }
2255 +
2256 + $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
2257 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
2258 + return $error_message;
2259 + }
2260 +
2261 + // Parse response body
2262 + $response_body = wp_remote_retrieve_body($response);
2263 + //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
2264 +
2265 + $response_data = json_decode($response_body, true);
2266 +
2267 + if (json_last_error() !== JSON_ERROR_NONE) {
2268 + $error = json_last_error_msg();
2269 + //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
2270 + //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
2271 + return "Failed to parse API response: $error";
2272 + }
2273 +
2274 + // Handle different response formats based on provider
2275 + if (strpos($selected_model, 'gemini-embedding') === 0) {
2276 + // Gemini API response format
2277 + if (isset($response_data['embedding']['values'])) {
2278 + $embedding_dimensions = count($response_data['embedding']['values']);
2279 + //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
2280 +
2281 + // Check if embedding dimensions are as expected (should be 1536)
2282 + if ($embedding_dimensions !== 1536) {
2283 + //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
2284 + }
2285 +
2286 + return $response_data['embedding']['values'];
2287 + } else {
2288 + //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
2289 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
2290 +
2291 + if (isset($response_data['error'])) {
2292 + $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
2293 + //error_log('[MXCHAT-EMBED] ' . $error_message);
2294 + return $error_message;
2295 + }
2296 +
2297 + $error_message = "Invalid Gemini API response format: No embedding found";
2298 + //error_log('[MXCHAT-EMBED] ' . $error_message);
2299 + return $error_message;
2300 + }
2301 + } else {
2302 + // OpenAI/Voyage API response format
2303 + if (isset($response_data['data'][0]['embedding'])) {
2304 + $embedding_dimensions = count($response_data['data'][0]['embedding']);
2305 + //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
2306 +
2307 + // Check if embedding dimensions are as expected
2308 + if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
2309 + ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
2310 + //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
2311 + }
2312 +
2313 + return $response_data['data'][0]['embedding'];
2314 + } else {
2315 + //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
2316 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
2317 +
2318 + if (isset($response_data['error'])) {
2319 + $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
2320 + //error_log('[MXCHAT-EMBED] ' . $error_message);
2321 + return $error_message;
2322 + }
2323 +
2324 + $error_message = "Invalid API response format: No embedding found";
2325 + //error_log('[MXCHAT-EMBED] ' . $error_message);
2326 + return $error_message;
2327 + }
2328 + }
2329 +}
2330 +
2331 +/**
2332 + * Get bot-specific options for multi-bot functionality
2333 + * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
2334 + */
2335 +private function get_bot_options($bot_id = 'default') {
2336 + //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
2337 +
2338 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2339 + //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
2340 + return array();
2341 + }
2342 +
2343 + $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
2344 +
2345 + if (!empty($bot_options)) {
2346 + //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
2347 + if (isset($bot_options['similarity_threshold'])) {
2348 + //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
2349 + }
2350 + }
2351 +
2352 + return is_array($bot_options) ? $bot_options : array();
2353 +}
2354 +
2355 +/**
2356 + * Get bot-specific Pinecone configuration
2357 + * Used in the knowledge retrieval functions
2358 + */
2359 +// Also add debugging to your get_bot_pinecone_config function
2360 +private function get_bot_pinecone_config($bot_id = 'default') {
2361 + //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
2362 +
2363 + // If default bot or multi-bot add-on not active, use default Pinecone config
2364 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2365 + //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
2366 + $addon_options = get_option('mxchat_pinecone_addon_options', array());
2367 + $config = array(
2368 + 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
2369 + 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
2370 + 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
2371 + 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
2372 + );
2373 + //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
2374 + return $config;
2375 + }
2376 +
2377 + //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
2378 +
2379 + // Hook for multi-bot add-on to provide bot-specific Pinecone config
2380 + $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2381 +
2382 + if (!empty($bot_pinecone_config)) {
2383 + //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
2384 + //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
2385 + //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
2386 + //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
2387 + } else {
2388 + //error_log("MXCHAT DEBUG: Filter returned empty config!");
2389 + }
2390 +
2391 + return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
2392 +}
2393 +
2394 +
2395 +public function mxchat_ajax_dismiss_completed_status() {
2396 + try {
2397 + // Verify the request
2398 + check_ajax_referer('mxchat_status_nonce', 'nonce');
2399 +
2400 + if (!current_user_can('manage_options')) {
2401 + wp_send_json_error('Unauthorized access');
2402 + exit;
2403 + }
2404 +
2405 + $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
2406 +
2407 + if ($card_type === 'pdf') {
2408 + // Clear PDF status
2409 + $pdf_url = get_transient('mxchat_last_pdf_url');
2410 + if ($pdf_url) {
2411 + delete_transient('mxchat_pdf_status_' . md5($pdf_url));
2412 + delete_transient('mxchat_last_pdf_url');
2413 + }
2414 + } elseif ($card_type === 'sitemap') {
2415 + // Clear sitemap status
2416 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
2417 + if ($sitemap_url) {
2418 + delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
2419 + delete_transient('mxchat_last_sitemap_url');
2420 + }
2421 + }
2422 +
2423 + wp_send_json_success(array('message' => 'Status dismissed successfully'));
2424 +
2425 + } catch (Exception $e) {
2426 + wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
2427 + }
2428 +}
2429 +
2430 +/**
2431 + * Render completed status cards on page load
2432 + * This ensures completed processing status persists through page refreshes
2433 + */
2434 +public function mxchat_render_completed_status_cards() {
2435 + $output = '';
2436 +
2437 + // Check for completed PDF status
2438 + $pdf_url = get_transient('mxchat_last_pdf_url');
2439 + if ($pdf_url) {
2440 + $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
2441 + if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
2442 + $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
2443 + }
2444 + }
2445 +
2446 + // Check for completed sitemap status
2447 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
2448 + if ($sitemap_url) {
2449 + $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
2450 + if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
2451 + $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
2452 + }
2453 + }
2454 +
2455 + return $output;
2456 +}
2457 +
2458 +/**
2459 + * Render PDF status card HTML
2460 + */
2461 +private function mxchat_render_pdf_status_card($status, $pdf_url) {
2462 + $html = '<div class="mxchat-status-card" data-card-type="pdf">';
2463 + $html .= '<div class="mxchat-status-header">';
2464 + $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
2465 +
2466 + // Add dismiss button for completed status
2467 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
2468 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
2469 + }
2470 +
2471 + // Process Batch button for processing status
2472 + if ($status['status'] === 'processing') {
2473 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
2474 + data-process-type="pdf"
2475 + data-url="' . esc_attr($pdf_url) . '">
2476 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
2477 + }
2478 +
2479 + // Add status badges
2480 + if ($status['status'] === 'error') {
2481 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
2482 + } elseif ($status['status'] === 'complete') {
2483 + if ($status['failed_pages'] > 0) {
2484 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
2485 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
2486 + } else {
2487 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
2488 + }
2489 + }
2490 +
2491 + $html .= '</div>'; // End header
2492 +
2493 + // Progress bar
2494 + $html .= '<div class="mxchat-progress-bar">';
2495 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
2496 + $html .= '</div>';
2497 +
2498 + // Status details
2499 + $html .= '<div class="mxchat-status-details">';
2500 + $html .= '<p>' . sprintf(
2501 + esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
2502 + $status['processed_pages'],
2503 + $status['total_pages'],
2504 + $status['percentage']
2505 + ) . '</p>';
2506 +
2507 + // Show failed pages count if any
2508 + if ($status['failed_pages'] > 0) {
2509 + $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
2510 + }
2511 +
2512 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
2513 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
2514 +
2515 + // Add completion summary if available AND it's an array
2516 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
2517 + $summary = $status['completion_summary'];
2518 + $html .= '<div class="mxchat-completion-summary">';
2519 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
2520 + $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
2521 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
2522 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
2523 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
2524 + $html .= '</div>';
2525 + }
2526 +
2527 + // Add failed pages list if any AND it's an array
2528 + if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
2529 + $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
2530 + }
2531 +
2532 + // Add error message if any
2533 + if (isset($status['error']) && !empty($status['error'])) {
2534 + $html .= '<div class="mxchat-error-notice">';
2535 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
2536 + $html .= '</div>';
2537 + }
2538 +
2539 + $html .= '</div>'; // End details
2540 + $html .= '</div>'; // End card
2541 +
2542 + return $html;
2543 +}
2544 +/**
2545 + * Render sitemap status card HTML
2546 + */
2547 +private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
2548 + $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
2549 + $html .= '<div class="mxchat-status-header">';
2550 + $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
2551 +
2552 + // Add dismiss button for completed status
2553 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
2554 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
2555 + }
2556 +
2557 + // Process Batch button for processing status
2558 + if ($status['status'] === 'processing') {
2559 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
2560 + data-process-type="sitemap"
2561 + data-url="' . esc_attr($sitemap_url) . '">
2562 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
2563 + }
2564 +
2565 + // Add status badges
2566 + if ($status['status'] === 'error') {
2567 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
2568 + } elseif ($status['status'] === 'complete') {
2569 + if ($status['failed_urls'] > 0) {
2570 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
2571 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
2572 + } else {
2573 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
2574 + }
2575 + }
2576 +
2577 + $html .= '</div>'; // End header
2578 +
2579 + // Progress bar
2580 + $html .= '<div class="mxchat-progress-bar">';
2581 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
2582 + $html .= '</div>';
2583 +
2584 + // Status details
2585 + $html .= '<div class="mxchat-status-details">';
2586 + $html .= '<p>' . sprintf(
2587 + esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
2588 + $status['processed_urls'],
2589 + $status['total_urls'],
2590 + $status['percentage']
2591 + ) . '</p>';
2592 +
2593 + // Show failed URLs count if any
2594 + if ($status['failed_urls'] > 0) {
2595 + $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
2596 + }
2597 +
2598 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
2599 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
2600 +
2601 + // Add completion summary if available AND it's an array
2602 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
2603 + $summary = $status['completion_summary'];
2604 + $html .= '<div class="mxchat-completion-summary">';
2605 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
2606 + $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
2607 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
2608 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
2609 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
2610 + $html .= '</div>';
2611 + }
2612 +
2613 + // Add error messages if any (but not the failed URLs list)
2614 + if (!empty($status['error']) || !empty($status['last_error'])) {
2615 + $html .= '<div class="mxchat-error-notice">';
2616 +
2617 + if (!empty($status['error'])) {
2618 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
2619 + }
2620 +
2621 + if (!empty($status['last_error'])) {
2622 + $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
2623 + }
2624 +
2625 + $html .= '</div>';
2626 + }
2627 +
2628 + $html .= '</div>'; // End details
2629 + $html .= '</div>'; // End card
2630 +
2631 + return $html;
2632 +}
2633 +
2634 +
2635 +/**
2636 + * Render failed pages list
2637 + */
2638 +private function mxchat_render_failed_pages_list($failed_pages_list) {
2639 + // Validate that $failed_pages_list is an array and not empty
2640 + if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
2641 + return '';
2642 + }
2643 +
2644 + $html = '<div class="mxchat-error-notice">';
2645 + $html .= '<div class="mxchat-failed-pages-container">';
2646 + $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
2647 + $html .= '<details>';
2648 + $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
2649 + $html .= '<div class="mxchat-failed-pages-list">';
2650 +
2651 + // Create table for failed pages
2652 + $html .= '<table class="widefat striped">';
2653 + $html .= '<thead><tr>';
2654 + $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
2655 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
2656 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
2657 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
2658 + $html .= '</tr></thead><tbody>';
2659 +
2660 + // Sort failed pages by most recent
2661 + $sorted_failed_pages = $failed_pages_list;
2662 + usort($sorted_failed_pages, function($a, $b) {
2663 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
2664 + });
2665 +
2666 + foreach ($sorted_failed_pages as $item) {
2667 + // Ensure $item is an array before accessing its elements
2668 + if (!is_array($item)) {
2669 + continue;
2670 + }
2671 +
2672 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
2673 + $html .= '<tr>';
2674 + $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
2675 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
2676 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
2677 + $html .= '<td>' . esc_html($time_ago) . '</td>';
2678 + $html .= '</tr>';
2679 + }
2680 +
2681 + $html .= '</tbody></table>';
2682 + $html .= '</div></details></div></div>';
2683 +
2684 + return $html;
2685 +}
2686 +
2687 +/**
2688 + * Render failed URLs list
2689 + */
2690 +private function mxchat_render_failed_urls_list($failed_urls_list) {
2691 + // Validate that $failed_urls_list is an array and not empty
2692 + if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
2693 + return '';
2694 + }
2695 +
2696 + $html = '<div class="mxchat-failed-urls-container">';
2697 + $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
2698 + $html .= '<details>';
2699 + $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
2700 + $html .= '<div class="mxchat-failed-urls-list">';
2701 +
2702 + // Create table for failed URLs
2703 + $html .= '<table class="widefat striped">';
2704 + $html .= '<thead><tr>';
2705 + $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
2706 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
2707 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
2708 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
2709 + $html .= '</tr></thead><tbody>';
2710 +
2711 + // Sort failed URLs by most recent
2712 + $sorted_failed_urls = $failed_urls_list;
2713 + usort($sorted_failed_urls, function($a, $b) {
2714 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
2715 + });
2716 +
2717 + // Show up to 50 failed URLs
2718 + $display_urls = array_slice($sorted_failed_urls, 0, 50);
2719 +
2720 + foreach ($display_urls as $item) {
2721 + // Ensure $item is an array before accessing its elements
2722 + if (!is_array($item)) {
2723 + continue;
2724 + }
2725 +
2726 + $url = $item['url'] ?? '';
2727 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
2728 +
2729 + // Truncate URL for display
2730 + $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
2731 +
2732 + $html .= '<tr>';
2733 + $html .= '<td style="word-break: break-all;">';
2734 + if (!empty($url)) {
2735 + $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
2736 + } else {
2737 + $html .= esc_html__('Unknown URL', 'mxchat');
2738 + }
2739 + $html .= '</td>';
2740 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
2741 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
2742 + $html .= '<td>' . esc_html($time_ago) . '</td>';
2743 + $html .= '</tr>';
2744 + }
2745 +
2746 + $html .= '</tbody></table>';
2747 +
2748 + if (count($failed_urls_list) > 50) {
2749 + $html .= '<div class="mxchat-failed-urls-more">+ ' .
2750 + (count($failed_urls_list) - 50) .
2751 + ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
2752 + }
2753 +
2754 + $html .= '</div></details></div>';
2755 +
2756 + return $html;
2757 +}
2758 +
2759 +/**
2760 + * Get all ACF fields for a specific post
2761 + */
2762 +public function mxchat_get_acf_fields_for_post($post_id) {
2763 + if (!function_exists('get_fields')) {
2764 + return array();
2765 + }
2766 +
2767 + $fields = get_fields($post_id);
2768 + if (!$fields || !is_array($fields)) {
2769 + return array();
2770 + }
2771 +
2772 + return $fields;
2773 +}
2774 +
2775 +/**
2776 + * Format ACF field values for content extraction
2777 + */
2778 +public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
2779 + if (empty($value)) {
2780 + return '';
2781 + }
2782 +
2783 + // Handle WP_Post objects first (THIS IS THE KEY FIX)
2784 + if ($value instanceof WP_Post) {
2785 + return $value->post_title ?: '';
2786 + }
2787 +
2788 + // Handle other WP objects
2789 + if (is_object($value)) {
2790 + if (isset($value->post_title)) {
2791 + return $value->post_title;
2792 + } elseif (isset($value->display_name)) {
2793 + return $value->display_name;
2794 + } elseif (isset($value->name)) {
2795 + return $value->name;
2796 + } elseif (method_exists($value, '__toString')) {
2797 + try {
2798 + return (string) $value;
2799 + } catch (Exception $e) {
2800 + return '';
2801 + }
2802 + }
2803 + // For any other objects, return empty string
2804 + return '';
2805 + }
2806 +
2807 + // Handle different ACF field types
2808 + if (is_array($value)) {
2809 + // Check if it's an image/file field
2810 + if (isset($value['url'])) {
2811 + // Image field - return alt text, title, or caption
2812 + if (!empty($value['alt'])) {
2813 + return $value['alt'];
2814 + } elseif (!empty($value['title'])) {
2815 + return $value['title'];
2816 + } elseif (!empty($value['caption'])) {
2817 + return $value['caption'];
2818 + } else {
2819 + return ''; // Don't include just the URL
2820 + }
2821 + }
2822 +
2823 + // Check if it's a post object or relationship field
2824 + if (isset($value['post_title'])) {
2825 + return $value['post_title'];
2826 + }
2827 +
2828 + // Check if it's a user field
2829 + if (isset($value['display_name'])) {
2830 + return $value['display_name'];
2831 + }
2832 +
2833 + // Check if it's a taxonomy term
2834 + if (isset($value['name']) && isset($value['taxonomy'])) {
2835 + return $value['name'];
2836 + }
2837 +
2838 + // Check if it's a select field with label
2839 + if (isset($value['label'])) {
2840 + return $value['label'];
2841 + }
2842 +
2843 + // Check for repeater field or flexible content
2844 + if (is_numeric(key($value))) {
2845 + $sub_values = array();
2846 + foreach ($value as $sub_item) {
2847 + if (is_array($sub_item)) {
2848 + // For repeater/flexible content, extract text values
2849 + $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
2850 + if (!empty($sub_text)) {
2851 + $sub_values[] = $sub_text;
2852 + }
2853 + } elseif ($sub_item instanceof WP_Post) {
2854 + // Handle WP_Post objects in arrays
2855 + $sub_values[] = $sub_item->post_title ?: '';
2856 + } else {
2857 + $sub_values[] = (string) $sub_item;
2858 + }
2859 + }
2860 + return implode(', ', array_filter($sub_values));
2861 + }
2862 +
2863 + // For other arrays, try to extract meaningful text
2864 + $text_values = array();
2865 + foreach ($value as $key => $val) {
2866 + if (is_string($val) && !empty(trim($val))) {
2867 + $text_values[] = trim($val);
2868 + } elseif ($val instanceof WP_Post) {
2869 + // Handle WP_Post objects in associative arrays
2870 + $text_values[] = $val->post_title ?: '';
2871 + } elseif (is_array($val) && isset($val['post_title'])) {
2872 + $text_values[] = $val['post_title'];
2873 + } elseif (is_array($val) && isset($val['name'])) {
2874 + $text_values[] = $val['name'];
2875 + }
2876 + }
2877 +
2878 + return implode(', ', array_filter($text_values));
2879 + }
2880 +
2881 + // Handle boolean values
2882 + if (is_bool($value)) {
2883 + return $value ? 'Yes' : 'No';
2884 + }
2885 +
2886 + // Handle numeric values
2887 + if (is_numeric($value)) {
2888 + return (string) $value;
2889 + }
2890 +
2891 + // Handle string values
2892 + if (is_string($value)) {
2893 + return trim($value);
2894 + }
2895 +
2896 + // For anything else that we can't handle, return empty string
2897 + // This prevents the "Object could not be converted to string" error
2898 + return '';
2899 +}
2900 +
2901 +/**
2902 + * Extract text from complex ACF array structures
2903 + */
2904 +private function mxchat_extract_text_from_acf_array($array) {
2905 + if (!is_array($array)) {
2906 + return '';
2907 + }
2908 +
2909 + $text_parts = array();
2910 +
2911 + foreach ($array as $key => $value) {
2912 + if (is_string($value) && !empty(trim($value))) {
2913 + // Skip keys that are likely to be IDs or technical values
2914 + if (!is_numeric($value) || strlen($value) > 10) {
2915 + $text_parts[] = trim($value);
2916 + }
2917 + } elseif ($value instanceof WP_Post) {
2918 + // Handle WP_Post objects
2919 + $text_parts[] = $value->post_title ?: '';
2920 + } elseif (is_array($value)) {
2921 + if (isset($value['post_title'])) {
2922 + $text_parts[] = $value['post_title'];
2923 + } elseif (isset($value['name'])) {
2924 + $text_parts[] = $value['name'];
2925 + } elseif (isset($value['label'])) {
2926 + $text_parts[] = $value['label'];
2927 + }
2928 + } elseif (is_object($value)) {
2929 + // Handle other objects safely
2930 + if (isset($value->post_title)) {
2931 + $text_parts[] = $value->post_title;
2932 + } elseif (isset($value->name)) {
2933 + $text_parts[] = $value->name;
2934 + } elseif (isset($value->display_name)) {
2935 + $text_parts[] = $value->display_name;
2936 + }
2937 + }
2938 + }
2939 +
2940 + return implode(', ', array_filter($text_parts));
2941 +}
2942 +
2943 +public function mxchat_handle_post_update($post_id, $post, $update) {
2944 + // Basic validation checks
2945 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
2946 + return;
2947 + }
2948 +
2949 + $post_type = $post->post_type;
2950 +
2951 + // Check if sync is enabled for this post type
2952 + $should_sync = false;
2953 +
2954 + // Check built-in post types first
2955 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
2956 + $should_sync = true;
2957 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
2958 + $should_sync = true;
2959 + } else {
2960 + // Check custom post types
2961 + $option_name = 'mxchat_auto_sync_' . $post_type;
2962 + if (get_option($option_name) === '1') {
2963 + $should_sync = true;
2964 + }
2965 + }
2966 +
2967 + if (!$should_sync) {
2968 + return;
2969 + }
2970 +
2971 + // Check if we have stored the previous status and URL in our transients
2972 + $previous_status_key = 'mxchat_prev_status_' . $post_id;
2973 + $previous_status = get_transient($previous_status_key);
2974 +
2975 + $previous_url_key = 'mxchat_prev_url_' . $post_id;
2976 + $previous_url = get_transient($previous_url_key);
2977 +
2978 + // If the post was previously published but is now not published, remove from knowledge base
2979 + if ($previous_status === 'publish' && $post->post_status !== 'publish') {
2980 + // Use the stored URL from when it was published, or fall back to current permalink
2981 + $source_url = $previous_url ?: get_permalink($post_id);
2982 +
2983 + if ($source_url) {
2984 + // Check if Pinecone is enabled
2985 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
2986 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2987 +
2988 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
2989 + // Delete from Pinecone
2990 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
2991 + } else {
2992 + // Delete from WordPress DB
2993 + global $wpdb;
2994 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2995 +
2996 + $result = $wpdb->delete(
2997 + $table_name,
2998 + array('source_url' => $source_url),
2999 + array('%s')
3000 + );
3001 + }
3002 + }
3003 +
3004 + // Clean up the transients and exit early
3005 + delete_transient($previous_status_key);
3006 + delete_transient($previous_url_key);
3007 + return;
3008 + }
3009 +
3010 + // Store the current status for next time (if this is an update)
3011 + if ($update) {
3012 + set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
3013 +
3014 + // If the post is currently published, also store its URL
3015 + if ($post->post_status === 'publish') {
3016 + $current_url = get_permalink($post_id);
3017 + set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
3018 + }
3019 + }
3020 +
3021 + // Only process currently published content for adding/updating
3022 + if ($post->post_status === 'publish') {
3023 + // Get the source URL
3024 + $source_url = get_permalink($post_id);
3025 +
3026 + // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
3027 + $title = get_the_title($post_id);
3028 + $content = get_post_field('post_content', $post_id);
3029 +
3030 + // Apply WordPress content filters to get properly formatted content
3031 + $content = apply_filters('the_content', $content);
3032 +
3033 + // Strip tags but preserve structure
3034 + $content = wp_strip_all_tags($content);
3035 +
3036 + // Combine title and content
3037 + $final_content = $title . "\n\n" . $content;
3038 +
3039 + // For custom post types like job_listing, include additional fields
3040 + if ($post_type === 'job_listing') {
3041 + // Add job-specific meta if available
3042 + $job_location = get_post_meta($post_id, '_job_location', true);
3043 + if (!empty($job_location)) {
3044 + $final_content .= "\n\nLocation: " . $job_location;
3045 + }
3046 +
3047 + // Get job type terms
3048 + $job_types = get_the_terms($post_id, 'job_listing_type');
3049 + if (!empty($job_types) && !is_wp_error($job_types)) {
3050 + $types = array();
3051 + foreach ($job_types as $type) {
3052 + $types[] = $type->name;
3053 + }
3054 + $final_content .= "\n\nJob Type: " . implode(', ', $types);
3055 + }
3056 +
3057 + // Get company name if available
3058 + $company_name = get_post_meta($post_id, '_company_name', true);
3059 + if (!empty($company_name)) {
3060 + $final_content .= "\n\nCompany: " . $company_name;
3061 + }
3062 + }
3063 +
3064 + // Get API key with proper model detection
3065 + $options = get_option('mxchat_options');
3066 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3067 +
3068 + if (strpos($selected_model, 'voyage') === 0) {
3069 + $api_key = $options['voyage_api_key'] ?? '';
3070 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3071 + $api_key = $options['gemini_api_key'] ?? '';
3072 + } else {
3073 + $api_key = $options['api_key'] ?? '';
3074 + }
3075 +
3076 + if (empty($api_key)) {
3077 + return;
3078 + }
3079 +
3080 + // Use the centralized utility function for storage
3081 + $result = MxChat_Utils::submit_content_to_db(
3082 + $final_content,
3083 + $source_url,
3084 + $api_key,
3085 + md5($source_url) // Vector ID for Pinecone
3086 + );
3087 +
3088 + // After successful storage, apply role restriction based on tags
3089 + if (!is_wp_error($result)) {
3090 + $this->apply_role_restriction_to_post($post_id, $source_url);
3091 + }
3092 + }
3093 +
3094 + // Clean up the stored previous status if not used above
3095 + if ($previous_status !== 'publish' || $post->post_status === 'publish') {
3096 + delete_transient($previous_status_key);
3097 + delete_transient($previous_url_key);
3098 + }
3099 +}
3100 +
3101 +/**
3102 + * Store the post status and URL before update to detect status transitions
3103 + * This runs before the post is actually updated in the database
3104 + */
3105 +public function mxchat_store_pre_update_status($post_id, $data) {
3106 + // Get the current post from database (before update)
3107 + $current_post = get_post($post_id);
3108 +
3109 + if ($current_post) {
3110 + // Store the current status temporarily
3111 + $status_key = 'mxchat_prev_status_' . $post_id;
3112 + set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
3113 +
3114 + // If the post is currently published, also store its URL
3115 + if ($current_post->post_status === 'publish') {
3116 + $url_key = 'mxchat_prev_url_' . $post_id;
3117 + $current_url = get_permalink($post_id);
3118 + set_transient($url_key, $current_url, HOUR_IN_SECONDS);
3119 + }
3120 + }
3121 +}
3122 +
3123 +public function mxchat_handle_post_delete($post_id) {
3124 + // Get post data before it's deleted
3125 + $post = get_post($post_id);
3126 +
3127 + // Basic validation
3128 + if (!$post || wp_is_post_revision($post_id)) {
3129 + return;
3130 + }
3131 +
3132 + $post_type = $post->post_type;
3133 +
3134 + // Check if sync is enabled for this post type
3135 + $should_sync = false;
3136 +
3137 + // Check built-in post types first
3138 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
3139 + $should_sync = true;
3140 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
3141 + $should_sync = true;
3142 + } else {
3143 + // Check custom post types
3144 + $option_name = 'mxchat_auto_sync_' . $post_type;
3145 + if (get_option($option_name) === '1') {
3146 + $should_sync = true;
3147 + }
3148 + }
3149 +
3150 + if (!$should_sync) {
3151 + return;
3152 + }
3153 +
3154 + // Get the URL before post is deleted
3155 + $source_url = get_permalink($post_id);
3156 + if (!$source_url) {
3157 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
3158 + return;
3159 + }
3160 +
3161 + // Check if Pinecone is enabled
3162 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3163 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3164 +
3165 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3166 + // Delete from Pinecone
3167 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
3168 + } else {
3169 + // Delete from WordPress DB
3170 + global $wpdb;
3171 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3172 +
3173 + $result = $wpdb->delete(
3174 + $table_name,
3175 + array('source_url' => $source_url),
3176 + array('%s')
3177 + );
3178 +
3179 + if ($result === false) {
3180 + //error_log('MXChat: WordPress DB deletion failed for URL: ' . $source_url);
3181 + }
3182 + }
3183 +}
3184 +
3185 +
3186 + /**
3187 + * Deletes data from Pinecone using a source URL
3188 + */
3189 + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
3190 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3191 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3192 +
3193 + if (empty($host) || empty($api_key)) {
3194 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
3195 + return false;
3196 + }
3197 +
3198 + $api_endpoint = "https://{$host}/vectors/delete";
3199 + $vector_id = md5($source_url);
3200 +
3201 + $request_body = array(
3202 + 'ids' => array($vector_id)
3203 + );
3204 +
3205 + $response = wp_remote_post($api_endpoint, array(
3206 + 'headers' => array(
3207 + 'Api-Key' => $api_key,
3208 + 'accept' => 'application/json',
3209 + 'content-type' => 'application/json'
3210 + ),
3211 + 'body' => wp_json_encode($request_body),
3212 + 'timeout' => 30
3213 + ));
3214 +
3215 + if (is_wp_error($response)) {
3216 + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
3217 + return false;
3218 + }
3219 +
3220 + $response_code = wp_remote_retrieve_response_code($response);
3221 + if ($response_code !== 200) {
3222 + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
3223 + return false;
3224 + }
3225 +
3226 + return true;
3227 + }
3228 +
3229 +
3230 +
3231 +public function mxchat_handle_product_change($post_id, $post, $update) {
3232 + if ($post->post_type !== 'product') {
3233 + return;
3234 + }
3235 +
3236 + if ($post->post_status === 'publish') {
3237 + add_action('shutdown', function() use ($post_id) {
3238 + $product = wc_get_product($post_id);
3239 + if ($product) {
3240 + $this->mxchat_store_product_embedding($product);
3241 + }
3242 + });
3243 + }
3244 +}
3245 +
3246 +/**
3247 + * Store WooCommerce product embeddings
3248 + */
3249 +private function mxchat_store_product_embedding($product) {
3250 + if (!isset($this->options['enable_woocommerce_integration']) ||
3251 + !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
3252 + return;
3253 + }
3254 +
3255 + $source_url = get_permalink($product->get_id());
3256 +
3257 + // Build product content
3258 + $title = $product->get_name();
3259 + $description = $product->get_description();
3260 + $short_description = $product->get_short_description();
3261 + $regular_price = $product->get_regular_price();
3262 + $sale_price = $product->get_sale_price();
3263 + $sku = $product->get_sku();
3264 +
3265 + // Format content consistently
3266 + $content = $title . "\n\n";
3267 +
3268 + if (!empty($description)) {
3269 + $content .= wp_strip_all_tags($description) . "\n\n";
3270 + }
3271 +
3272 + if (!empty($short_description)) {
3273 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
3274 + }
3275 +
3276 + $content .= "Price: $" . $regular_price . "\n";
3277 +
3278 + if (!empty($sale_price)) {
3279 + $content .= "Sale Price: $" . $sale_price . "\n";
3280 + }
3281 +
3282 + if (!empty($sku)) {
3283 + $content .= "SKU: " . $sku . "\n";
3284 + }
3285 +
3286 + // Get API key with proper model detection
3287 + $options = get_option('mxchat_options');
3288 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3289 +
3290 + if (strpos($selected_model, 'voyage') === 0) {
3291 + $api_key = $options['voyage_api_key'] ?? '';
3292 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3293 + $api_key = $options['gemini_api_key'] ?? '';
3294 + } else {
3295 + $api_key = $options['api_key'] ?? '';
3296 + }
3297 +
3298 + if (empty($api_key)) {
3299 + //error_log('MxChat Auto-sync: No API key configured for embedding model');
3300 + return;
3301 + }
3302 +
3303 + // Use the centralized utility function for storage
3304 + $result = MxChat_Utils::submit_content_to_db(
3305 + $content,
3306 + $source_url,
3307 + $api_key,
3308 + md5($source_url) // Vector ID for Pinecone
3309 + );
3310 +
3311 + // After successful storage, apply role restriction based on tags
3312 + if (!is_wp_error($result)) {
3313 + $this->apply_role_restriction_to_post($product->get_id(), $source_url);
3314 + }
3315 +
3316 + if (is_wp_error($result)) {
3317 + //error_log('MxChat WooCommerce sync failed for product ' . $product->get_id() . ': ' . $result->get_error_message());
3318 + }
3319 +}
3320 +
3321 +public function mxchat_handle_product_delete($post_id) {
3322 + if (get_post_type($post_id) !== 'product') {
3323 + return;
3324 + }
3325 +
3326 + $source_url = get_permalink($post_id);
3327 +
3328 + // Check if Pinecone is enabled
3329 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3330 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3331 +
3332 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3333 + // Delete from Pinecone
3334 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
3335 + } else {
3336 + // Delete from WordPress DB
3337 + global $wpdb;
3338 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3339 +
3340 + $wpdb->delete(
3341 + $table_name,
3342 + array('source_url' => $source_url),
3343 + array('%s')
3344 + );
3345 + }
3346 +}
3347 +
3348 +/**
3349 + * Handle individual Pinecone content deletion
3350 + */
3351 +public function mxchat_handle_pinecone_prompt_delete() {
3352 + // Check permissions
3353 + if (!current_user_can('manage_options')) {
3354 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
3355 + }
3356 +
3357 + // Verify nonce
3358 + if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
3359 + wp_die(esc_html__('Security check failed.', 'mxchat'));
3360 + }
3361 +
3362 + $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
3363 +
3364 + if (empty($vector_id)) {
3365 + set_transient('mxchat_admin_notice_error',
3366 + esc_html__('Invalid vector ID.', 'mxchat'),
3367 + 30
3368 + );
3369 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
3370 + exit;
3371 + }
3372 +
3373 + // Get Pinecone settings
3374 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3375 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3376 +
3377 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
3378 + set_transient('mxchat_admin_notice_error',
3379 + esc_html__('Pinecone is not properly configured.', 'mxchat'),
3380 + 30
3381 + );
3382 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
3383 + exit;
3384 + }
3385 +
3386 + // Delete from Pinecone
3387 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
3388 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
3389 + $vector_id,
3390 + $pinecone_options['mxchat_pinecone_api_key'],
3391 + $pinecone_options['mxchat_pinecone_host']
3392 + );
3393 +
3394 + if ($result['success']) {
3395 + // No cache clearing needed since we removed caching
3396 + set_transient('mxchat_admin_notice_success',
3397 + esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
3398 + 30
3399 + );
3400 + } else {
3401 + set_transient('mxchat_admin_notice_error',
3402 + esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
3403 + 30
3404 + );
3405 + }
3406 +
3407 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
3408 + exit;
3409 +}
3410 +/**
3411 + * Handle individual Pinecone content deletion via AJAX
3412 + */
3413 +public function ajax_mxchat_delete_pinecone_prompt() {
3414 + // Verify nonce and permissions
3415 + if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
3416 + wp_send_json_error('Invalid nonce');
3417 + exit;
3418 + }
3419 +
3420 + if (!current_user_can('manage_options')) {
3421 + wp_send_json_error('Unauthorized access');
3422 + exit;
3423 + }
3424 +
3425 + $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
3426 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
3427 +
3428 + if (empty($vector_id)) {
3429 + wp_send_json_error('Missing vector ID');
3430 + exit;
3431 + }
3432 +
3433 + // Get bot-specific Pinecone settings
3434 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
3435 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
3436 +
3437 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3438 +
3439 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
3440 + wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
3441 + exit;
3442 + }
3443 +
3444 + // Delete from the correct Pinecone index
3445 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
3446 + $vector_id,
3447 + $pinecone_options['mxchat_pinecone_api_key'],
3448 + $pinecone_options['mxchat_pinecone_host']
3449 + );
3450 +
3451 + if ($result['success']) {
3452 + // No cache clearing needed since we removed caching
3453 + wp_send_json_success(array(
3454 + 'message' => 'Entry deleted successfully from Pinecone',
3455 + 'vector_id' => $vector_id,
3456 + 'bot_id' => $bot_id
3457 + ));
3458 + } else {
3459 + wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
3460 + }
3461 +
3462 + exit;
3463 +}
3464 +
3465 +/**
3466 + * Get hierarchical roles for dropdown
3467 + */
3468 +public function mxchat_get_role_options() {
3469 + return array(
3470 + 'public' => __('Public (Everyone)', 'mxchat'),
3471 + 'logged_in' => __('Logged In Users', 'mxchat'),
3472 + 'subscriber' => __('Subscribers & Above', 'mxchat'),
3473 + 'contributor' => __('Contributors & Above', 'mxchat'),
3474 + 'author' => __('Authors & Above', 'mxchat'),
3475 + 'editor' => __('Editors & Above', 'mxchat'),
3476 + 'administrator' => __('Administrators Only', 'mxchat')
3477 + );
3478 +}
3479 +
3480 +/**
3481 + * Check if user has access to content based on role restriction
3482 + */
3483 +public function mxchat_user_has_content_access($role_restriction) {
3484 + // Public content is always accessible
3485 + if ($role_restriction === 'public' || empty($role_restriction)) {
3486 + return true;
3487 + }
3488 +
3489 + // Check if user is logged in for logged_in restriction
3490 + if ($role_restriction === 'logged_in') {
3491 + return is_user_logged_in();
3492 + }
3493 +
3494 + // If not logged in, no access to role-restricted content
3495 + if (!is_user_logged_in()) {
3496 + return false;
3497 + }
3498 +
3499 + $user = wp_get_current_user();
3500 + $user_roles = $user->roles;
3501 +
3502 + if (empty($user_roles)) {
3503 + return false;
3504 + }
3505 +
3506 + // Define role hierarchy (higher number = higher access)
3507 + $hierarchy = array(
3508 + 'subscriber' => 1,
3509 + 'contributor' => 2,
3510 + 'author' => 3,
3511 + 'editor' => 4,
3512 + 'administrator' => 5
3513 + );
3514 +
3515 + // Get required level
3516 + $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
3517 +
3518 + // Check if user has required level or higher
3519 + foreach ($user_roles as $user_role) {
3520 + $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
3521 + if ($user_level >= $required_level) {
3522 + return true;
3523 + }
3524 + }
3525 +
3526 + return false;
3527 +}
3528 +
3529 +/**
3530 + * Handle role restriction updates via AJAX
3531 + * Removed cache clearing call since we removed caching
3532 + */
3533 +public function ajax_mxchat_update_role_restriction() {
3534 + // Verify nonce and permissions
3535 + if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
3536 + wp_send_json_error('Invalid nonce');
3537 + exit;
3538 + }
3539 +
3540 + if (!current_user_can('manage_options')) {
3541 + wp_send_json_error('Unauthorized access');
3542 + exit;
3543 + }
3544 +
3545 + $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
3546 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
3547 + $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
3548 +
3549 + if (empty($entry_id)) {
3550 + wp_send_json_error('Invalid entry ID');
3551 + exit;
3552 + }
3553 +
3554 + // Get knowledge manager instance to validate role restriction
3555 + $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
3556 + $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
3557 + if (!in_array($role_restriction, $valid_roles)) {
3558 + wp_send_json_error('Invalid role restriction');
3559 + exit;
3560 + }
3561 +
3562 + global $wpdb;
3563 +
3564 + if ($data_source === 'pinecone') {
3565 + // Handle Pinecone role restriction (stored separately in WordPress table)
3566 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
3567 +
3568 + // Use REPLACE to insert or update the role restriction
3569 + $result = $wpdb->replace(
3570 + $roles_table,
3571 + array(
3572 + 'vector_id' => $entry_id,
3573 + 'role_restriction' => $role_restriction,
3574 + 'updated_at' => current_time('mysql')
3575 + ),
3576 + array('%s', '%s', '%s')
3577 + );
3578 +
3579 + // No cache clearing needed since we removed caching
3580 +
3581 + } else {
3582 + // Handle WordPress database role restriction (existing functionality)
3583 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3584 +
3585 + $result = $wpdb->update(
3586 + $table_name,
3587 + array('role_restriction' => $role_restriction),
3588 + array('id' => absint($entry_id)),
3589 + array('%s'),
3590 + array('%d')
3591 + );
3592 + }
3593 +
3594 + if ($result === false) {
3595 + wp_send_json_error('Database update failed: ' . $wpdb->last_error);
3596 + exit;
3597 + }
3598 +
3599 + wp_send_json_success(array(
3600 + 'message' => 'Role restriction updated successfully',
3601 + 'role_restriction' => $role_restriction,
3602 + 'data_source' => $data_source,
3603 + 'entry_id' => $entry_id
3604 + ));
3605 + exit;
3606 +}
3607 +
3608 +// ========================================
3609 +// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
3610 +// Add these to your MxChat_Knowledge_Manager class
3611 +// ========================================
3612 +
3613 +/**
3614 + * Initialize role-based content hooks
3615 + * Add this call to your __construct() or mxchat_init_hooks() method
3616 + */
3617 +private function mxchat_init_role_hooks() {
3618 + // AJAX handlers for tag-role mappings
3619 + add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
3620 + add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
3621 + add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
3622 + add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
3623 +
3624 + // Hook to automatically update role restrictions when tags are added/removed
3625 + add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
3626 +
3627 + // Hook to apply role restrictions on auto-sync
3628 + add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
3629 +}
3630 +
3631 +/**
3632 + * Add tag-role mapping via AJAX
3633 + */
3634 +public function ajax_add_tag_role_mapping() {
3635 + // Verify nonce and permissions
3636 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
3637 +
3638 + if (!current_user_can('manage_options')) {
3639 + wp_send_json_error('Unauthorized access');
3640 + exit;
3641 + }
3642 +
3643 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
3644 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
3645 +
3646 + if (empty($tag_slug)) {
3647 + wp_send_json_error('Tag slug is required');
3648 + exit;
3649 + }
3650 +
3651 + // Validate role restriction
3652 + $valid_roles = array_keys($this->mxchat_get_role_options());
3653 + if (!in_array($role_restriction, $valid_roles)) {
3654 + wp_send_json_error('Invalid role restriction');
3655 + exit;
3656 + }
3657 +
3658 + // Check if tag exists in WordPress
3659 + $term = get_term_by('slug', $tag_slug, 'post_tag');
3660 + if (!$term) {
3661 + wp_send_json_error('Tag does not exist in WordPress');
3662 + exit;
3663 + }
3664 +
3665 + // Get existing mappings
3666 + $mappings = get_option('mxchat_tag_role_mappings', array());
3667 +
3668 + // Check if mapping already exists
3669 + if (isset($mappings[$tag_slug])) {
3670 + wp_send_json_error('Mapping for this tag already exists');
3671 + exit;
3672 + }
3673 +
3674 + // Add new mapping
3675 + $mappings[$tag_slug] = $role_restriction;
3676 + update_option('mxchat_tag_role_mappings', $mappings);
3677 +
3678 + wp_send_json_success(array(
3679 + 'message' => 'Tag-role mapping added successfully',
3680 + 'tag_slug' => $tag_slug,
3681 + 'role_restriction' => $role_restriction
3682 + ));
3683 + exit;
3684 +}
3685 +
3686 +/**
3687 + * Delete tag-role mapping via AJAX
3688 + */
3689 +public function ajax_delete_tag_role_mapping() {
3690 + // Verify nonce and permissions
3691 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
3692 +
3693 + if (!current_user_can('manage_options')) {
3694 + wp_send_json_error('Unauthorized access');
3695 + exit;
3696 + }
3697 +
3698 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
3699 +
3700 + if (empty($tag_slug)) {
3701 + wp_send_json_error('Tag slug is required');
3702 + exit;
3703 + }
3704 +
3705 + // Get existing mappings
3706 + $mappings = get_option('mxchat_tag_role_mappings', array());
3707 +
3708 + // Check if mapping exists
3709 + if (!isset($mappings[$tag_slug])) {
3710 + wp_send_json_error('Mapping does not exist');
3711 + exit;
3712 + }
3713 +
3714 + // Remove mapping
3715 + unset($mappings[$tag_slug]);
3716 + update_option('mxchat_tag_role_mappings', $mappings);
3717 +
3718 + wp_send_json_success(array(
3719 + 'message' => 'Tag-role mapping deleted successfully',
3720 + 'tag_slug' => $tag_slug
3721 + ));
3722 + exit;
3723 +}
3724 +
3725 +/**
3726 + * Get all tag-role mappings via AJAX
3727 + */
3728 +public function ajax_get_tag_role_mappings() {
3729 + // Verify nonce and permissions
3730 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
3731 +
3732 + if (!current_user_can('manage_options')) {
3733 + wp_send_json_error('Unauthorized access');
3734 + exit;
3735 + }
3736 +
3737 + // Get mappings
3738 + $mappings = get_option('mxchat_tag_role_mappings', array());
3739 + $role_options = $this->mxchat_get_role_options();
3740 +
3741 + $formatted_mappings = array();
3742 +
3743 + foreach ($mappings as $tag_slug => $role_restriction) {
3744 + // Get tag object
3745 + $term = get_term_by('slug', $tag_slug, 'post_tag');
3746 +
3747 + // Count posts with this tag
3748 + $post_count = 0;
3749 + if ($term) {
3750 + $post_count = $term->count;
3751 + }
3752 +
3753 + $formatted_mappings[] = array(
3754 + 'tag_slug' => $tag_slug,
3755 + 'role_restriction' => $role_restriction,
3756 + 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
3757 + 'post_count' => $post_count
3758 + );
3759 + }
3760 +
3761 + wp_send_json_success(array(
3762 + 'mappings' => $formatted_mappings
3763 + ));
3764 + exit;
3765 +}
3766 +
3767 +/**
3768 + * Bulk update role restrictions for all existing content with mapped tags
3769 + */
3770 +public function ajax_bulk_update_tag_roles() {
3771 + // Verify nonce and permissions
3772 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
3773 +
3774 + if (!current_user_can('manage_options')) {
3775 + wp_send_json_error('Unauthorized access');
3776 + exit;
3777 + }
3778 +
3779 + // Get mappings
3780 + $mappings = get_option('mxchat_tag_role_mappings', array());
3781 +
3782 + if (empty($mappings)) {
3783 + wp_send_json_error('No tag-role mappings found');
3784 + exit;
3785 + }
3786 +
3787 + global $wpdb;
3788 +
3789 + // Check if using Pinecone
3790 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3791 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3792 +
3793 + $updated_count = 0;
3794 + $details = array();
3795 +
3796 + foreach ($mappings as $tag_slug => $role_restriction) {
3797 + // Get all posts with this tag
3798 + $posts = get_posts(array(
3799 + 'tag' => $tag_slug,
3800 + 'post_type' => 'any',
3801 + 'posts_per_page' => -1,
3802 + 'fields' => 'ids',
3803 + 'post_status' => 'publish'
3804 + ));
3805 +
3806 + if (empty($posts)) {
3807 + continue;
3808 + }
3809 +
3810 + $tag_updated = 0;
3811 +
3812 + foreach ($posts as $post_id) {
3813 + $source_url = get_permalink($post_id);
3814 + if (!$source_url) {
3815 + continue;
3816 + }
3817 +
3818 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3819 + // Update Pinecone role restriction
3820 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
3821 + $vector_id = md5($source_url);
3822 +
3823 + $result = $wpdb->replace(
3824 + $roles_table,
3825 + array(
3826 + 'vector_id' => $vector_id,
3827 + 'role_restriction' => $role_restriction,
3828 + 'updated_at' => current_time('mysql')
3829 + ),
3830 + array('%s', '%s', '%s')
3831 + );
3832 + } else {
3833 + // Update WordPress DB
3834 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3835 +
3836 + $result = $wpdb->update(
3837 + $table_name,
3838 + array('role_restriction' => $role_restriction),
3839 + array('source_url' => $source_url),
3840 + array('%s'),
3841 + array('%s')
3842 + );
3843 + }
3844 +
3845 + if ($result !== false) {
3846 + $tag_updated++;
3847 + $updated_count++;
3848 + }
3849 + }
3850 +
3851 + if ($tag_updated > 0) {
3852 + $details[] = sprintf(
3853 + 'Tag "%s" (%s): %d posts updated',
3854 + $tag_slug,
3855 + $role_restriction,
3856 + $tag_updated
3857 + );
3858 + }
3859 + }
3860 +
3861 + wp_send_json_success(array(
3862 + 'message' => 'Bulk update completed',
3863 + 'updated_count' => $updated_count,
3864 + 'tags_processed' => count($mappings),
3865 + 'details' => $details
3866 + ));
3867 + exit;
3868 +}
3869 +
3870 +/**
3871 + * Handle tag changes on posts (when tags are added or removed)
3872 + */
3873 +public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
3874 + // Only process post tags
3875 + if ($taxonomy !== 'post_tag') {
3876 + return;
3877 + }
3878 +
3879 + // Get tag-role mappings
3880 + $mappings = get_option('mxchat_tag_role_mappings', array());
3881 +
3882 + if (empty($mappings)) {
3883 + return;
3884 + }
3885 +
3886 + // Get the post's URL
3887 + $source_url = get_permalink($object_id);
3888 + if (!$source_url) {
3889 + return;
3890 + }
3891 +
3892 + // Determine the highest role restriction based on tags
3893 + $highest_role = 'public';
3894 + $role_hierarchy = array(
3895 + 'public' => 0,
3896 + 'logged_in' => 1,
3897 + 'subscriber' => 2,
3898 + 'contributor' => 3,
3899 + 'author' => 4,
3900 + 'editor' => 5,
3901 + 'administrator' => 6
3902 + );
3903 +
3904 + // Get all current tags for the post
3905 + $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
3906 +
3907 + // Find the highest role restriction among the tags
3908 + foreach ($current_tags as $tag_slug) {
3909 + if (isset($mappings[$tag_slug])) {
3910 + $role = $mappings[$tag_slug];
3911 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
3912 + $highest_role = $role;
3913 + }
3914 + }
3915 + }
3916 +
3917 + // Update the role restriction in the database
3918 + global $wpdb;
3919 +
3920 + // Check if using Pinecone
3921 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3922 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3923 +
3924 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3925 + // Update Pinecone role restriction
3926 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
3927 + $vector_id = md5($source_url);
3928 +
3929 + $wpdb->replace(
3930 + $roles_table,
3931 + array(
3932 + 'vector_id' => $vector_id,
3933 + 'role_restriction' => $highest_role,
3934 + 'updated_at' => current_time('mysql')
3935 + ),
3936 + array('%s', '%s', '%s')
3937 + );
3938 + } else {
3939 + // Update WordPress DB
3940 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3941 +
3942 + $wpdb->update(
3943 + $table_name,
3944 + array('role_restriction' => $highest_role),
3945 + array('source_url' => $source_url),
3946 + array('%s'),
3947 + array('%s')
3948 + );
3949 + }
3950 +}
3951 +
3952 +/**
3953 + * Apply role restriction after content is stored (for auto-sync)
3954 + */
3955 +public function apply_role_restriction_after_storage($post_id, $source_url) {
3956 + // Get tag-role mappings
3957 + $mappings = get_option('mxchat_tag_role_mappings', array());
3958 +
3959 + if (empty($mappings)) {
3960 + return;
3961 + }
3962 +
3963 + // Get all tags for the post
3964 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
3965 +
3966 + if (empty($post_tags)) {
3967 + return;
3968 + }
3969 +
3970 + // Determine the highest role restriction based on tags
3971 + $highest_role = 'public';
3972 + $role_hierarchy = array(
3973 + 'public' => 0,
3974 + 'logged_in' => 1,
3975 + 'subscriber' => 2,
3976 + 'contributor' => 3,
3977 + 'author' => 4,
3978 + 'editor' => 5,
3979 + 'administrator' => 6
3980 + );
3981 +
3982 + foreach ($post_tags as $tag_slug) {
3983 + if (isset($mappings[$tag_slug])) {
3984 + $role = $mappings[$tag_slug];
3985 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
3986 + $highest_role = $role;
3987 + }
3988 + }
3989 + }
3990 +
3991 + // If no restricted tags found, return (leave as public)
3992 + if ($highest_role === 'public') {
3993 + return;
3994 + }
3995 +
3996 + // Update the role restriction
3997 + global $wpdb;
3998 +
3999 + // Check if using Pinecone
4000 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4001 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4002 +
4003 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4004 + // Update Pinecone role restriction
4005 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4006 + $vector_id = md5($source_url);
4007 +
4008 + $wpdb->replace(
4009 + $roles_table,
4010 + array(
4011 + 'vector_id' => $vector_id,
4012 + 'role_restriction' => $highest_role,
4013 + 'updated_at' => current_time('mysql')
4014 + ),
4015 + array('%s', '%s', '%s')
4016 + );
4017 + } else {
4018 + // Update WordPress DB
4019 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4020 +
4021 + $wpdb->update(
4022 + $table_name,
4023 + array('role_restriction' => $highest_role),
4024 + array('source_url' => $source_url),
4025 + array('%s'),
4026 + array('%s')
4027 + );
4028 + }
4029 +}
4030 +
4031 +
4032 + // ========================================
4033 + // HELPER METHODS
4034 + // ========================================
4035 +
4036 + /**
4037 + * Check if user has required permissions for content processing
4038 + */
4039 + private function mxchat_check_user_permissions() {
4040 + if (!current_user_can('manage_options')) {
4041 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
4042 + }
4043 + }
4044 +
4045 + /**
4046 + * Validate nonce for security
4047 + */
4048 + private function mxchat_validate_nonce($nonce_name, $nonce_action) {
4049 + if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
4050 + wp_die(esc_html__('Security check failed.', 'mxchat'));
4051 + }
4052 + }
4053 +
4054 + /**
4055 + * Get embedding API credentials
4056 + */
4057 + private function mxchat_get_embedding_credentials() {
4058 + $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
4059 +
4060 + if (strpos($embedding_model, 'text-embedding-') !== false) {
4061 + return array(
4062 + 'type' => 'openai',
4063 + 'api_key' => $this->options['api_key'] ?? ''
4064 + );
4065 + } elseif (strpos($embedding_model, 'voyage-') !== false) {
4066 + return array(
4067 + 'type' => 'voyage',
4068 + 'api_key' => $this->options['voyage_api_key'] ?? ''
4069 + );
4070 + } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
4071 + return array(
4072 + 'type' => 'gemini',
4073 + 'api_key' => $this->options['gemini_api_key'] ?? ''
4074 + );
4075 + }
4076 +
4077 + return array('type' => 'unknown', 'api_key' => '');
4078 + }
4079 +
4080 + /**
4081 + * Log processing errors
4082 + */
4083 + private function mxchat_log_processing_error($operation, $error_message) {
4084 + //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
4085 + }
4086 +
4087 + /**
4088 + * Set admin notice transient
4089 + */
4090 + private function mxchat_set_admin_notice($type, $message) {
4091 + set_transient("mxchat_admin_notice_{$type}", $message, 30);
4092 + }
4093 +
4094 + /**
4095 + * Get Pinecone manager instance for vector operations
4096 + */
4097 + private function mxchat_get_pinecone_manager() {
4098 + return MxChat_Pinecone_Manager::get_instance();
4099 + }
4100 +
4101 +
4102 + // ========================================
4103 +// DATABASE QUEUE TABLE MANAGEMENT
4104 +// ========================================
4105 +
4106 +/**
4107 + * Create queue table on plugin activation
4108 + * Call this from your plugin activation hook
4109 + */
4110 +public function mxchat_create_queue_table() {
4111 + global $wpdb;
4112 +
4113 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4114 + $charset_collate = $wpdb->get_charset_collate();
4115 +
4116 + $sql = "CREATE TABLE IF NOT EXISTS $table_name (
4117 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
4118 + queue_id varchar(64) NOT NULL,
4119 + item_type varchar(20) NOT NULL,
4120 + item_data longtext NOT NULL,
4121 + status varchar(20) NOT NULL DEFAULT 'pending',
4122 + bot_id varchar(50) NOT NULL DEFAULT 'default',
4123 + priority int(11) NOT NULL DEFAULT 0,
4124 + attempts int(11) NOT NULL DEFAULT 0,
4125 + max_attempts int(11) NOT NULL DEFAULT 3,
4126 + error_message text DEFAULT NULL,
4127 + created_at datetime NOT NULL,
4128 + started_at datetime DEFAULT NULL,
4129 + completed_at datetime DEFAULT NULL,
4130 + PRIMARY KEY (id),
4131 + KEY queue_id (queue_id),
4132 + KEY status (status),
4133 + KEY item_type (item_type),
4134 + KEY priority (priority)
4135 + ) $charset_collate;";
4136 +
4137 + require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
4138 + dbDelta($sql);
4139 +
4140 + // Also create a meta table for queue metadata
4141 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
4142 +
4143 + $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
4144 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
4145 + queue_id varchar(64) NOT NULL,
4146 + meta_key varchar(255) NOT NULL,
4147 + meta_value longtext,
4148 + PRIMARY KEY (id),
4149 + KEY queue_id (queue_id),
4150 + KEY meta_key (meta_key)
4151 + ) $charset_collate;";
4152 +
4153 + dbDelta($meta_sql);
4154 +}
4155 +
4156 +/**
4157 + * Add items to the processing queue
4158 + *
4159 + * @param string $queue_id Unique identifier for this queue batch
4160 + * @param string $item_type Type of item (url, pdf_page)
4161 + * @param array $items Array of items to queue
4162 + * @param string $bot_id Bot ID for processing
4163 + * @return int Number of items queued
4164 + */
4165 +private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
4166 + global $wpdb;
4167 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4168 +
4169 + $queued_count = 0;
4170 + $priority = 0;
4171 +
4172 + foreach ($items as $item) {
4173 + $result = $wpdb->insert(
4174 + $table_name,
4175 + array(
4176 + 'queue_id' => $queue_id,
4177 + 'item_type' => $item_type,
4178 + 'item_data' => wp_json_encode($item),
4179 + 'status' => 'pending',
4180 + 'bot_id' => $bot_id,
4181 + 'priority' => $priority,
4182 + 'attempts' => 0,
4183 + 'max_attempts' => 3,
4184 + 'created_at' => current_time('mysql')
4185 + ),
4186 + array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
4187 + );
4188 +
4189 + if ($result) {
4190 + $queued_count++;
4191 + }
4192 +
4193 + $priority++; // Process in order
4194 + }
4195 +
4196 + return $queued_count;
4197 +}
4198 +
4199 +/**
4200 + * Store queue metadata (total counts, source URL, etc.)
4201 + */
4202 +private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
4203 + global $wpdb;
4204 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
4205 +
4206 + // Check if meta exists
4207 + $existing = $wpdb->get_var($wpdb->prepare(
4208 + "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
4209 + $queue_id,
4210 + $meta_key
4211 + ));
4212 +
4213 + if ($existing) {
4214 + // Update
4215 + $wpdb->update(
4216 + $meta_table,
4217 + array('meta_value' => maybe_serialize($meta_value)),
4218 + array('queue_id' => $queue_id, 'meta_key' => $meta_key),
4219 + array('%s'),
4220 + array('%s', '%s')
4221 + );
4222 + } else {
4223 + // Insert
4224 + $wpdb->insert(
4225 + $meta_table,
4226 + array(
4227 + 'queue_id' => $queue_id,
4228 + 'meta_key' => $meta_key,
4229 + 'meta_value' => maybe_serialize($meta_value)
4230 + ),
4231 + array('%s', '%s', '%s')
4232 + );
4233 + }
4234 +}
4235 +
4236 +/**
4237 + * Get queue metadata
4238 + */
4239 +private function mxchat_get_queue_meta($queue_id, $meta_key) {
4240 + global $wpdb;
4241 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
4242 +
4243 + $value = $wpdb->get_var($wpdb->prepare(
4244 + "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
4245 + $queue_id,
4246 + $meta_key
4247 + ));
4248 +
4249 + return maybe_unserialize($value);
4250 +}
4251 +
4252 +// ========================================
4253 +// AJAX QUEUE PROCESSING HANDLERS
4254 +// ========================================
4255 +
4256 +/**
4257 + * AJAX: Get next item from queue to process
4258 + */
4259 +public function ajax_mxchat_get_next_queue_item() {
4260 + // Verify nonce and permissions
4261 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
4262 +
4263 + if (!current_user_can('manage_options')) {
4264 + wp_send_json_error('Unauthorized access');
4265 + }
4266 +
4267 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
4268 +
4269 + if (empty($queue_id)) {
4270 + wp_send_json_error('Missing queue ID');
4271 + }
4272 +
4273 + global $wpdb;
4274 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4275 +
4276 + // Get next pending item with retry logic for failed items
4277 + $next_item = $wpdb->get_row($wpdb->prepare(
4278 + "SELECT * FROM $table_name
4279 + WHERE queue_id = %s
4280 + AND status IN ('pending', 'failed')
4281 + AND attempts < max_attempts
4282 + ORDER BY priority ASC, id ASC
4283 + LIMIT 1",
4284 + $queue_id
4285 + ));
4286 +
4287 + if (!$next_item) {
4288 + // No more items - queue complete
4289 + wp_send_json_success(array(
4290 + 'complete' => true,
4291 + 'message' => 'Queue processing complete'
4292 + ));
4293 + }
4294 +
4295 + // Mark item as processing
4296 + $wpdb->update(
4297 + $table_name,
4298 + array(
4299 + 'status' => 'processing',
4300 + 'started_at' => current_time('mysql'),
4301 + 'attempts' => $next_item->attempts + 1
4302 + ),
4303 + array('id' => $next_item->id),
4304 + array('%s', '%s', '%d'),
4305 + array('%d')
4306 + );
4307 +
4308 + wp_send_json_success(array(
4309 + 'complete' => false,
4310 + 'item' => array(
4311 + 'id' => $next_item->id,
4312 + 'type' => $next_item->item_type,
4313 + 'data' => json_decode($next_item->item_data, true),
4314 + 'bot_id' => $next_item->bot_id,
4315 + 'attempt' => $next_item->attempts + 1
4316 + )
4317 + ));
4318 +}
4319 +
4320 +/**
4321 + * AJAX: Process a single queue item
4322 + */
4323 +public function ajax_mxchat_process_queue_item() {
4324 + // Verify nonce and permissions
4325 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
4326 +
4327 + if (!current_user_can('manage_options')) {
4328 + wp_send_json_error('Unauthorized access');
4329 + }
4330 +
4331 + $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
4332 + $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
4333 + $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
4334 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
4335 +
4336 + if (empty($item_id) || empty($item_type)) {
4337 + wp_send_json_error('Missing item data');
4338 + }
4339 +
4340 + global $wpdb;
4341 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4342 +
4343 + // Process based on item type
4344 + try {
4345 + set_time_limit(60); // Give processing 60 seconds
4346 +
4347 + $result = false;
4348 + $error_message = '';
4349 +
4350 + switch ($item_type) {
4351 + case 'url':
4352 + $result = $this->mxchat_process_queue_url($item_data, $bot_id);
4353 + break;
4354 +
4355 + case 'pdf_page':
4356 + $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
4357 + break;
4358 +
4359 + default:
4360 + throw new Exception('Unknown item type: ' . $item_type);
4361 + }
4362 +
4363 + if (is_wp_error($result)) {
4364 + throw new Exception($result->get_error_message());
4365 + }
4366 +
4367 + if ($result === false) {
4368 + throw new Exception('Processing returned false - item may be empty or invalid');
4369 + }
4370 +
4371 + // Mark as completed
4372 + $wpdb->update(
4373 + $table_name,
4374 + array(
4375 + 'status' => 'completed',
4376 + 'completed_at' => current_time('mysql'),
4377 + 'error_message' => null
4378 + ),
4379 + array('id' => $item_id),
4380 + array('%s', '%s', '%s'),
4381 + array('%d')
4382 + );
4383 +
4384 + wp_send_json_success(array(
4385 + 'processed' => true,
4386 + 'item_id' => $item_id,
4387 + 'message' => 'Item processed successfully'
4388 + ));
4389 +
4390 + } catch (Exception $e) {
4391 + $error_message = $e->getMessage();
4392 +
4393 + // Get current attempt count
4394 + $item = $wpdb->get_row($wpdb->prepare(
4395 + "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
4396 + $item_id
4397 + ));
4398 +
4399 + // Check if we've exhausted retries
4400 + if ($item && $item->attempts >= $item->max_attempts) {
4401 + // Permanently failed
4402 + $wpdb->update(
4403 + $table_name,
4404 + array(
4405 + 'status' => 'failed',
4406 + 'error_message' => $error_message
4407 + ),
4408 + array('id' => $item_id),
4409 + array('%s', '%s'),
4410 + array('%d')
4411 + );
4412 +
4413 + wp_send_json_error(array(
4414 + 'message' => 'Item failed after maximum attempts: ' . $error_message,
4415 + 'permanent_failure' => true,
4416 + 'item_id' => $item_id
4417 + ));
4418 + } else {
4419 + // Mark for retry
4420 + $wpdb->update(
4421 + $table_name,
4422 + array(
4423 + 'status' => 'failed',
4424 + 'error_message' => $error_message
4425 + ),
4426 + array('id' => $item_id),
4427 + array('%s', '%s'),
4428 + array('%d')
4429 + );
4430 +
4431 + wp_send_json_error(array(
4432 + 'message' => 'Item processing failed, will retry: ' . $error_message,
4433 + 'can_retry' => true,
4434 + 'item_id' => $item_id,
4435 + 'attempts' => $item ? $item->attempts : 0
4436 + ));
4437 + }
4438 + }
4439 +}
4440 +
4441 +/**
4442 + * Process a URL from the queue
4443 + */
4444 +private function mxchat_process_queue_url($item_data, $bot_id = 'default') {
4445 + $url = isset($item_data['url']) ? $item_data['url'] : '';
4446 +
4447 + if (empty($url)) {
4448 + return new WP_Error('invalid_url', 'URL is empty');
4449 + }
4450 +
4451 + // Fetch URL content
4452 + $response = wp_remote_get($url, array(
4453 + 'timeout' => 30,
4454 + 'redirection' => 5,
4455 + 'user-agent' => 'MxChat/1.0'
4456 + ));
4457 +
4458 + if (is_wp_error($response)) {
4459 + return $response;
4460 + }
4461 +
4462 + $response_code = wp_remote_retrieve_response_code($response);
4463 + if ($response_code !== 200) {
4464 + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error');
4465 + }
4466 +
4467 + $html = wp_remote_retrieve_body($response);
4468 +
4469 + if (empty($html)) {
4470 + return new WP_Error('empty_response', 'Empty response body');
4471 + }
4472 +
4473 + // Extract and sanitize content
4474 + $content = $this->mxchat_extract_main_content($html);
4475 + $sanitized = $this->mxchat_sanitize_content_for_api($content);
4476 +
4477 + if (empty($sanitized)) {
4478 + // Not an error - just no content found (maybe a redirect or empty page)
4479 + return false;
4480 + }
4481 +
4482 + // Get bot-specific API key
4483 + $bot_options = $this->get_bot_options($bot_id);
4484 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4485 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4486 +
4487 + if (strpos($selected_model, 'voyage') === 0) {
4488 + $api_key = $options['voyage_api_key'] ?? '';
4489 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4490 + $api_key = $options['gemini_api_key'] ?? '';
4491 + } else {
4492 + $api_key = $options['api_key'] ?? '';
4493 + }
4494 +
4495 + if (empty($api_key)) {
4496 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
4497 + }
4498 +
4499 + // Submit to database
4500 + $result = MxChat_Utils::submit_content_to_db(
4501 + $sanitized,
4502 + $url,
4503 + $api_key,
4504 + null,
4505 + $bot_id
4506 + );
4507 +
4508 + return $result;
4509 +}
4510 +
4511 +/**
4512 + * Process a PDF page from the queue
4513 + */
4514 +private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
4515 + $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
4516 + $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
4517 + $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
4518 + $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
4519 +
4520 + if (empty($pdf_path) || !file_exists($pdf_path)) {
4521 + return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
4522 + }
4523 +
4524 + if ($page_number < 1) {
4525 + return new WP_Error('invalid_page', 'Invalid page number');
4526 + }
4527 +
4528 + try {
4529 + $parser = new \Smalot\PdfParser\Parser();
4530 + $pdf = $parser->parseFile($pdf_path);
4531 + $pages = $pdf->getPages();
4532 +
4533 + if (!isset($pages[$page_number - 1])) {
4534 + return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
4535 + }
4536 +
4537 + $text = $pages[$page_number - 1]->getText();
4538 +
4539 + if (empty($text)) {
4540 + // Not an error - just an empty page
4541 + return false;
4542 + }
4543 +
4544 + $sanitized = $this->mxchat_sanitize_content_for_api($text);
4545 +
4546 + if (empty($sanitized)) {
4547 + return false;
4548 + }
4549 +
4550 + // Create metadata
4551 + $metadata = array(
4552 + 'document_type' => 'pdf',
4553 + 'total_pages' => $total_pages,
4554 + 'current_page' => $page_number,
4555 + 'source_url' => $pdf_url
4556 + );
4557 +
4558 + $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
4559 + $page_url = esc_url($pdf_url . "#page=" . $page_number);
4560 +
4561 + // Get bot-specific API key
4562 + $bot_options = $this->get_bot_options($bot_id);
4563 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4564 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4565 +
4566 + if (strpos($selected_model, 'voyage') === 0) {
4567 + $api_key = $options['voyage_api_key'] ?? '';
4568 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4569 + $api_key = $options['gemini_api_key'] ?? '';
4570 + } else {
4571 + $api_key = $options['api_key'] ?? '';
4572 + }
4573 +
4574 + if (empty($api_key)) {
4575 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
4576 + }
4577 +
4578 + // Submit to database
4579 + $result = MxChat_Utils::submit_content_to_db(
4580 + $content_with_metadata,
4581 + $page_url,
4582 + $api_key,
4583 + null,
4584 + $bot_id
4585 + );
4586 +
4587 + return $result;
4588 +
4589 + } catch (Exception $e) {
4590 + return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
4591 + }
4592 +}
4593 +
4594 +/**
4595 + * AJAX: Get queue processing status
4596 + */
4597 +public function ajax_mxchat_get_queue_status() {
4598 + // Verify nonce and permissions
4599 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
4600 +
4601 + if (!current_user_can('manage_options')) {
4602 + wp_send_json_error('Unauthorized access');
4603 + }
4604 +
4605 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
4606 +
4607 + if (empty($queue_id)) {
4608 + wp_send_json_error('Missing queue ID');
4609 + }
4610 +
4611 + global $wpdb;
4612 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4613 +
4614 + // Get counts by status
4615 + $counts = $wpdb->get_results($wpdb->prepare(
4616 + "SELECT status, COUNT(*) as count
4617 + FROM $table_name
4618 + WHERE queue_id = %s
4619 + GROUP BY status",
4620 + $queue_id
4621 + ), OBJECT_K);
4622 +
4623 + $total = 0;
4624 + $completed = 0;
4625 + $failed = 0;
4626 + $processing = 0;
4627 + $pending = 0;
4628 +
4629 + foreach ($counts as $status => $data) {
4630 + $count = absint($data->count);
4631 + $total += $count;
4632 +
4633 + switch ($status) {
4634 + case 'completed':
4635 + $completed = $count;
4636 + break;
4637 + case 'failed':
4638 + $failed = $count;
4639 + break;
4640 + case 'processing':
4641 + $processing = $count;
4642 + break;
4643 + case 'pending':
4644 + $pending = $count;
4645 + break;
4646 + }
4647 + }
4648 +
4649 + // Calculate percentage
4650 + $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
4651 +
4652 + // Get failed items details
4653 + $failed_items = array();
4654 + if ($failed > 0) {
4655 + $failed_items = $wpdb->get_results($wpdb->prepare(
4656 + "SELECT item_type, item_data, error_message, attempts
4657 + FROM $table_name
4658 + WHERE queue_id = %s
4659 + AND status = 'failed'
4660 + AND attempts >= max_attempts
4661 + ORDER BY id DESC
4662 + LIMIT 50",
4663 + $queue_id
4664 + ));
4665 + }
4666 +
4667 + // Get queue metadata
4668 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
4669 + $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
4670 +
4671 + // Determine if queue is complete
4672 + $is_complete = ($pending === 0 && $processing === 0);
4673 +
4674 + wp_send_json_success(array(
4675 + 'queue_id' => $queue_id,
4676 + 'queue_type' => $queue_type,
4677 + 'source_url' => $source_url,
4678 + 'total' => $total,
4679 + 'completed' => $completed,
4680 + 'failed' => $failed,
4681 + 'processing' => $processing,
4682 + 'pending' => $pending,
4683 + 'percentage' => $percentage,
4684 + 'is_complete' => $is_complete,
4685 + 'failed_items' => $failed_items,
4686 + 'status' => $is_complete ? 'complete' : 'processing'
4687 + ));
4688 +}
4689 +
4690 +/**
4691 + * AJAX: Clear completed queue
4692 + */
4693 +public function ajax_mxchat_clear_queue() {
4694 + // Verify nonce and permissions
4695 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
4696 +
4697 + if (!current_user_can('manage_options')) {
4698 + wp_send_json_error('Unauthorized access');
4699 + }
4700 +
4701 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
4702 +
4703 + if (empty($queue_id)) {
4704 + wp_send_json_error('Missing queue ID');
4705 + }
4706 +
4707 + global $wpdb;
4708 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4709 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
4710 +
4711 + // Delete queue items
4712 + $wpdb->delete(
4713 + $table_name,
4714 + array('queue_id' => $queue_id),
4715 + array('%s')
4716 + );
4717 +
4718 + // Delete queue metadata
4719 + $wpdb->delete(
4720 + $meta_table,
4721 + array('queue_id' => $queue_id),
4722 + array('%s')
4723 + );
4724 +
4725 + wp_send_json_success(array(
4726 + 'message' => 'Queue cleared successfully'
4727 + ));
4728 +}
4729 +
4730 +/**
4731 + * AJAX: Retry failed items in queue
4732 + */
4733 +public function ajax_mxchat_retry_failed() {
4734 + // Verify nonce and permissions
4735 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
4736 +
4737 + if (!current_user_can('manage_options')) {
4738 + wp_send_json_error('Unauthorized access');
4739 + }
4740 +
4741 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
4742 +
4743 + if (empty($queue_id)) {
4744 + wp_send_json_error('Missing queue ID');
4745 + }
4746 +
4747 + global $wpdb;
4748 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4749 +
4750 + // Reset failed items to pending and reset attempt count
4751 + $updated = $wpdb->update(
4752 + $table_name,
4753 + array(
4754 + 'status' => 'pending',
4755 + 'attempts' => 0,
4756 + 'error_message' => null
4757 + ),
4758 + array(
4759 + 'queue_id' => $queue_id,
4760 + 'status' => 'failed'
4761 + ),
4762 + array('%s', '%d', '%s'),
4763 + array('%s', '%s')
4764 + );
4765 +
4766 + wp_send_json_success(array(
4767 + 'message' => 'Reset ' . $updated . ' failed items for retry',
4768 + 'reset_count' => $updated
4769 + ));
4770 +}
4771 +
4772 +
4773 +public function ajax_mxchat_mark_queue_complete() {
4774 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
4775 +
4776 + if (!current_user_can('manage_options')) {
4777 + wp_send_json_error('Unauthorized access');
4778 + }
4779 +
4780 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
4781 +
4782 + if (empty($queue_id)) {
4783 + wp_send_json_error('Missing queue ID');
4784 + }
4785 +
4786 + // Clear active queue transients
4787 + if (strpos($queue_id, 'sitemap_') === 0) {
4788 + delete_transient('mxchat_active_queue_sitemap');
4789 + } else if (strpos($queue_id, 'pdf_') === 0) {
4790 + delete_transient('mxchat_active_queue_pdf');
4791 + }
4792 +
4793 + wp_send_json_success(array('message' => 'Queue marked as complete'));
4794 +}
4795 +
4796 +
4797 + // ========================================
4798 + // STATIC ACCESS METHODS
4799 + // ========================================
4800 +
4801 + /**
4802 + * Get singleton instance
4803 + */
4804 + public static function get_instance() {
4805 + static $instance = null;
4806 + if ($instance === null) {
4807 + $instance = new self();
4808 + }
4809 + return $instance;
4810 + }
4811 +}
4812 +
4813 +// Initialize the Knowledge manager
8407 4814 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();