PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
← All changes | admin/class-knowledge-manager.php +11412 -7893 3.2.3 → 3.2.24 View file →
@@ -1,7894 +1,11413 @@
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 -
63 - // WordPress post management hooks
64 - add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
65 - add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
66 - add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
67 - add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
68 -
69 - // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
70 - // Priority 20 to run after ACF's own save (which runs at priority 10)
71 - add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
72 -
73 - add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
74 -
75 - // WooCommerce product hooks (if WooCommerce is active)
76 - if (class_exists('WooCommerce')) {
77 - add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
78 - add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
79 - add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
80 - add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
81 - }
82 -}
83 -
84 - /**
85 - * Get current options (refreshed)
86 - */
87 - private function mxchat_get_options() {
88 - if (empty($this->options)) {
89 - $this->options = get_option('mxchat_options', array());
90 - }
91 - return $this->options;
92 - }
93 -
94 -
95 - // ========================================
96 - // MAIN CONTENT SUBMISSION HANDLERS
97 - // ========================================
98 -
99 -public function mxchat_handle_content_submission() {
100 - // Check if the form was submitted and the user has permission.
101 - if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
102 - return;
103 - }
104 -
105 - // Verify the nonce.
106 - $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
107 - if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
108 - wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
109 - }
110 -
111 - // Sanitize the inputs.
112 - // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
113 - $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
114 - $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
115 -
116 - // Get bot_id from form submission
117 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
118 -
119 - // Get bot-specific options and API key
120 - $bot_options = $this->get_bot_options($bot_id);
121 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
122 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
123 -
124 - if (strpos($selected_model, 'voyage') === 0) {
125 - $api_key = $options['voyage_api_key'] ?? '';
126 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
127 - $api_key = $options['gemini_api_key'] ?? '';
128 - } else {
129 - $api_key = $options['api_key'] ?? '';
130 - }
131 -
132 - if (empty($api_key)) {
133 - set_transient('mxchat_admin_notice_error',
134 - esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
135 - 30
136 - );
137 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
138 - exit;
139 - }
140 -
141 - // Use centralized utility function with bot_id
142 - $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
143 -
144 - if (is_wp_error($result)) {
145 - set_transient('mxchat_admin_notice_error',
146 - esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
147 - 30
148 - );
149 - } else {
150 - set_transient('mxchat_admin_notice_success',
151 - esc_html__('Content successfully submitted!', 'mxchat'),
152 - 30
153 - );
154 - }
155 -
156 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
157 - exit;
158 -}
159 -
160 -public function mxchat_is_pdf_url($url, $response) {
161 - $content_type = wp_remote_retrieve_header($response, 'content-type');
162 - $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
163 -
164 - // Check Content-Disposition header for .pdf filename (Google Drive sends this)
165 - $disposition = wp_remote_retrieve_header($response, 'content-disposition');
166 - $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
167 -
168 - return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
169 -}
170 -
171 -
172 -public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
173 - if (!current_user_can('manage_options')) {
174 - return false;
175 - }
176 -
177 - $pdf_url = esc_url_raw($pdf_url);
178 - $upload_dir = wp_upload_dir();
179 -
180 - if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
181 - return false;
182 - }
183 -
184 - $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
185 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
186 -
187 - $response_body = wp_remote_retrieve_body($response);
188 - if (empty($response_body)) {
189 - return false;
190 - }
191 -
192 - if (!wp_mkdir_p(dirname($pdf_path))) {
193 - return false;
194 - }
195 -
196 - try {
197 - file_put_contents($pdf_path, $response_body);
198 -
199 - if (!file_exists($pdf_path)) {
200 - throw new Exception(__('Failed to save PDF file', 'mxchat'));
201 - }
202 -
203 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
204 -
205 - if ($total_pages === false || $total_pages < 1) {
206 - throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
207 - }
208 -
209 - // Create unique queue ID
210 - $queue_id = 'pdf_' . md5($pdf_url . time());
211 -
212 - // Create array of pages to process
213 - $pages = array();
214 - for ($i = 1; $i <= $total_pages; $i++) {
215 - $pages[] = array(
216 - 'pdf_path' => $pdf_path,
217 - 'pdf_url' => $pdf_url,
218 - 'page_number' => $i,
219 - 'total_pages' => $total_pages
220 - );
221 - }
222 -
223 - // Add pages to queue
224 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
225 -
226 - if ($queued_count === 0) {
227 - wp_delete_file($pdf_path);
228 - throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
229 - }
230 -
231 - // Store queue metadata
232 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
233 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
234 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
235 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
236 - $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
237 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
238 -
239 - // Store queue ID in transient for status tracking
240 - set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
241 - set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
242 -
243 - return 'queued';
244 -
245 - } catch (Exception $e) {
246 - if (file_exists($pdf_path)) {
247 - wp_delete_file($pdf_path);
248 - }
249 - return $e->getMessage();
250 - }
251 -}
252 -
253 -/**
254 - * Handle direct PDF file upload from the knowledge base page
255 - */
256 -public function mxchat_handle_pdf_file_submission() {
257 - if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
258 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
259 - }
260 -
261 - check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
262 -
263 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
264 -
265 - // Validate file upload
266 - if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
267 - $error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
268 - $error_messages = array(
269 - UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
270 - UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
271 - UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
272 - UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
273 - UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
274 - UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
275 - );
276 - $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
277 - set_transient('mxchat_admin_notice_error', $error_msg, 30);
278 - wp_safe_redirect(esc_url($redirect_url));
279 - exit;
280 - }
281 -
282 - $file = $_FILES['pdf_file'];
283 -
284 - // Validate MIME type
285 - $finfo = finfo_open(FILEINFO_MIME_TYPE);
286 - $mime_type = finfo_file($finfo, $file['tmp_name']);
287 - finfo_close($finfo);
288 -
289 - if ($mime_type !== 'application/pdf') {
290 - set_transient('mxchat_admin_notice_error',
291 - esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
292 - 30
293 - );
294 - wp_safe_redirect(esc_url($redirect_url));
295 - exit;
296 - }
297 -
298 - // Validate extension
299 - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
300 - if ($ext !== 'pdf') {
301 - set_transient('mxchat_admin_notice_error',
302 - esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
303 - 30
304 - );
305 - wp_safe_redirect(esc_url($redirect_url));
306 - exit;
307 - }
308 -
309 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
310 - $original_filename = sanitize_file_name($file['name']);
311 -
312 - $upload_dir = wp_upload_dir();
313 - if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
314 - set_transient('mxchat_admin_notice_error',
315 - esc_html__('WordPress upload directory is not writable.', 'mxchat'),
316 - 30
317 - );
318 - wp_safe_redirect(esc_url($redirect_url));
319 - exit;
320 - }
321 -
322 - $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
323 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
324 -
325 - if (!wp_mkdir_p(dirname($pdf_path))) {
326 - set_transient('mxchat_admin_notice_error',
327 - esc_html__('Failed to create upload directory.', 'mxchat'),
328 - 30
329 - );
330 - wp_safe_redirect(esc_url($redirect_url));
331 - exit;
332 - }
333 -
334 - // Move uploaded file
335 - if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
336 - set_transient('mxchat_admin_notice_error',
337 - esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
338 - 30
339 - );
340 - wp_safe_redirect(esc_url($redirect_url));
341 - exit;
342 - }
343 -
344 - try {
345 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
346 -
347 - if ($total_pages === false || $total_pages < 1) {
348 - throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
349 - }
350 -
351 - // Use original filename as the source identifier
352 - $source_label = 'upload://' . $original_filename;
353 -
354 - $queue_id = 'pdf_' . md5($source_label . time());
355 -
356 - $pages = array();
357 - for ($i = 1; $i <= $total_pages; $i++) {
358 - $pages[] = array(
359 - 'pdf_path' => $pdf_path,
360 - 'pdf_url' => $source_label,
361 - 'page_number' => $i,
362 - 'total_pages' => $total_pages,
363 - );
364 - }
365 -
366 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
367 -
368 - if ($queued_count === 0) {
369 - wp_delete_file($pdf_path);
370 - throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
371 - }
372 -
373 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
374 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
375 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
376 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
377 - $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
378 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
379 -
380 - set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
381 - set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
382 -
383 - set_transient('mxchat_admin_notice_success',
384 - sprintf(
385 - esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
386 - esc_html($original_filename),
387 - $total_pages
388 - ),
389 - 30
390 - );
391 -
392 - } catch (Exception $e) {
393 - if (file_exists($pdf_path)) {
394 - wp_delete_file($pdf_path);
395 - }
396 - set_transient('mxchat_admin_notice_error',
397 - esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
398 - 30
399 - );
400 - }
401 -
402 - wp_safe_redirect(esc_url($redirect_url));
403 - exit;
404 -}
405 -
406 -/**
407 - * Validate PDF and count pages with multiple parser attempts
408 - */
409 -private function mxchat_validate_and_count_pdf_pages($pdf_path) {
410 - // Method 1: Try with Smalot PDF Parser (your current method)
411 - try {
412 - mxchat_load_pdf_parser();
413 - $parser = new \Smalot\PdfParser\Parser();
414 - $pdf = $parser->parseFile($pdf_path);
415 - $pages = $pdf->getPages();
416 - $page_count = count($pages);
417 -
418 - if ($page_count > 0) {
419 - //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
420 - return $page_count;
421 - }
422 - } catch (Exception $e) {
423 - //error_log('Smalot PDF parser failed: ' . $e->getMessage());
424 - }
425 -
426 - // Method 2: Try with pdfinfo command (if available)
427 - if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
428 - try {
429 - $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
430 - $output = shell_exec($command);
431 -
432 - if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
433 - $page_count = intval($matches[1]);
434 - if ($page_count > 0) {
435 - //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
436 - return $page_count;
437 - }
438 - }
439 - } catch (Exception $e) {
440 - //error_log('pdfinfo command failed: ' . $e->getMessage());
441 - }
442 - }
443 -
444 - // Method 3: Try to repair PDF and parse again
445 - try {
446 - $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
447 - if ($repaired_path && $repaired_path !== $pdf_path) {
448 - mxchat_load_pdf_parser();
449 - $parser = new \Smalot\PdfParser\Parser();
450 - $pdf = $parser->parseFile($repaired_path);
451 - $pages = $pdf->getPages();
452 - $page_count = count($pages);
453 -
454 - if ($page_count > 0) {
455 - // Replace original with repaired version
456 - copy($repaired_path, $pdf_path);
457 - unlink($repaired_path);
458 - //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
459 - return $page_count;
460 - }
461 -
462 - // Clean up repaired file if it didn't work
463 - unlink($repaired_path);
464 - }
465 - } catch (Exception $e) {
466 - //error_log('PDF repair attempt failed: ' . $e->getMessage());
467 - }
468 -
469 - // Method 4: Manual PDF structure analysis (basic page count)
470 - try {
471 - $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
472 - if ($page_count > 0) {
473 - //error_log('PDF page count determined manually: ' . $page_count . ' pages');
474 - return $page_count;
475 - }
476 - } catch (Exception $e) {
477 - //error_log('Manual PDF analysis failed: ' . $e->getMessage());
478 - }
479 -
480 - //error_log('All PDF parsing methods failed for: ' . $pdf_path);
481 - return false;
482 -}
483 -
484 -/**
485 - * Check if shell_exec is disabled
486 - */
487 -private function mxchat_is_shell_disabled() {
488 - $disabled = explode(',', ini_get('disable_functions'));
489 - return in_array('shell_exec', $disabled);
490 -}
491 -
492 -/**
493 - * Attempt to repair PDF using basic methods
494 - */
495 -private function mxchat_attempt_pdf_repair($pdf_path) {
496 - try {
497 - $content = file_get_contents($pdf_path);
498 - if (!$content) {
499 - return false;
500 - }
501 -
502 - // Check if PDF starts with proper header
503 - if (substr($content, 0, 4) !== '%PDF') {
504 - // Try to find PDF header in the content
505 - $header_pos = strpos($content, '%PDF');
506 - if ($header_pos !== false && $header_pos < 1024) {
507 - // Remove junk before PDF header
508 - $content = substr($content, $header_pos);
509 - $repaired_path = $pdf_path . '.repaired';
510 - file_put_contents($repaired_path, $content);
511 - return $repaired_path;
512 - }
513 - }
514 -
515 - // Check for EOF marker
516 - $content = rtrim($content);
517 - if (!preg_match('/%%EOF\s*$/', $content)) {
518 - // Add EOF marker if missing
519 - $content .= "\n%%EOF";
520 - $repaired_path = $pdf_path . '.repaired';
521 - file_put_contents($repaired_path, $content);
522 - return $repaired_path;
523 - }
524 -
525 - } catch (Exception $e) {
526 - //error_log('PDF repair error: ' . $e->getMessage());
527 - }
528 -
529 - return false;
530 -}
531 -
532 -/**
533 - * Manual PDF page counting by analyzing PDF structure
534 - */
535 -private function mxchat_manual_pdf_page_count($pdf_path) {
536 - try {
537 - $content = file_get_contents($pdf_path);
538 - if (!$content) {
539 - return 0;
540 - }
541 -
542 - // Method 1: Count /Type /Page objects
543 - $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
544 - if ($page_count > 0) {
545 - return $page_count;
546 - }
547 -
548 - // Method 2: Look for /Count in pages object
549 - if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
550 - return intval($matches[1]);
551 - }
552 -
553 - // Method 3: Count page references
554 - $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
555 - if ($page_count > 0) {
556 - return $page_count;
557 - }
558 -
559 - } catch (Exception $e) {
560 - //error_log('Manual PDF analysis error: ' . $e->getMessage());
561 - }
562 -
563 - return 0;
564 -}
565 -
566 -
567 -public function mxchat_save_inline_prompt() {
568 - // DEBUG: Log what we're receiving
569 - //error_log('=== MXCHAT DEBUG ===');
570 - //error_log('POST data: ' . print_r($_POST, true));
571 - //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
572 -
573 - // Check for nonce security
574 - check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
575 -
576 - // If we get here, nonce passed
577 - //error_log('Nonce verification PASSED');
578 -
579 - // Verify permissions
580 - if (!current_user_can('manage_options')) {
581 - wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
582 - return;
583 - }
584 -
585 - global $wpdb;
586 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
587 -
588 - // Validate and sanitize input data
589 - $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
590 - $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
591 - $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
592 -
593 - if ($prompt_id > 0 && !empty($article_content)) {
594 - // Re-generate the embedding vector for the updated content
595 - $embedding_vector = $this->mxchat_generate_embedding($article_content);
596 - if (is_array($embedding_vector)) {
597 - // Serialize the embedding vector before storing it
598 - $embedding_vector_serialized = serialize($embedding_vector);
599 - // Update the prompt in the database
600 - $updated = $wpdb->update(
601 - $table_name,
602 - array(
603 - 'article_content' => $article_content,
604 - 'embedding_vector' => $embedding_vector_serialized,
605 - 'source_url' => $article_url,
606 - 'embedding_model' => MxChat_Utils::get_active_embedding_model(),
607 - ),
608 - array('id' => $prompt_id),
609 - array('%s', '%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: Save edited content — re-chunks and re-embeds as needed.
820 - * Works for both WordPress DB and Pinecone entries.
821 - */
822 -public function ajax_mxchat_save_entry_content() {
823 - check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
824 -
825 - if ( ! current_user_can('manage_options') ) {
826 - wp_send_json_error( array( 'message' => 'Permission denied.' ) );
827 - }
828 -
829 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
830 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
831 - $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
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 - $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
835 -
836 - if ( empty($content) ) {
837 - wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
838 - }
839 -
840 - // Get the embedding API key
841 - $options = get_option('mxchat_options', array());
842 - $api_key = '';
843 -
844 - if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
845 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
846 - $api_key = $bot_options['api_key'] ?? '';
847 - }
848 - if ( empty($api_key) ) {
849 - $api_key = $options['api_key'] ?? '';
850 - }
851 -
852 - global $wpdb;
853 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
854 -
855 - // If source_url is empty but we have an entry_id, look it up
856 - if ( empty($source_url) && $entry_id > 0 && $data_source === 'wordpress' ) {
857 - $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
858 - if ( $row && ! empty($row->source_url) ) {
859 - $source_url = $row->source_url;
860 - }
861 - }
862 -
863 - // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
864 - // so submit_content_to_db creates a replacement instead of a duplicate
865 - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
866 - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
867 - if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
868 - $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
869 - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
870 - // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
871 - if ( $is_legacy_manual ) {
872 - $source_url = '';
873 - }
874 - }
875 -
876 - // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
877 - $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
878 -
879 - // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
880 - $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
881 -
882 - if ( is_wp_error($result) ) {
883 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
884 - }
885 -
886 - wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
887 -}
888 -
889 -public function mxchat_get_pdf_processing_status($pdf_url) {
890 - $pdf_url = esc_url_raw($pdf_url);
891 - $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
892 -
893 - if (!$status || !is_array($status)) {
894 - return false;
895 - }
896 -
897 - // Check for stalled processing (no updates for 5 minutes)
898 - if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
899 - $status['status'] = 'error';
900 - $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
901 -
902 - // Save the updated status
903 - set_transient(
904 - sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
905 - array_map('sanitize_text_field', $status),
906 - DAY_IN_SECONDS
907 - );
908 - }
909 -
910 - $result = array(
911 - 'total_pages' => absint($status['total_pages']),
912 - 'processed_pages' => absint($status['processed_pages']),
913 - 'failed_pages' => absint($status['failed_pages'] ?? 0),
914 - 'percentage' => ($status['total_pages'] > 0)
915 - ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
916 - : 0,
917 - 'status' => sanitize_text_field($status['status']),
918 - 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
919 - 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
920 - 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
921 - );
922 -
923 - // Add error message if present
924 - if (isset($status['error']) && !empty($status['error'])) {
925 - $result['error'] = sanitize_text_field($status['error']);
926 - }
927 -
928 - return $result;
929 -}
930 -
931 -
932 -public function mxchat_handle_sitemap_submission() {
933 - // Check if the form was submitted and verify permissions
934 - if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
935 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
936 - }
937 -
938 - // Verify nonce
939 - check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
940 -
941 - // Validate URL
942 - if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
943 - set_transient('mxchat_admin_notice_error',
944 - esc_html__('Please provide a valid URL.', 'mxchat'),
945 - 30
946 - );
947 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
948 - exit;
949 - }
950 -
951 - $submitted_url = esc_url_raw($_POST['sitemap_url']);
952 -
953 - // Convert Google Drive sharing URLs to direct download URLs
954 - if ( strpos($submitted_url, 'drive.google.com') !== false ) {
955 - $file_id = '';
956 - if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
957 - $file_id = $m[1];
958 - } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
959 - $file_id = $m[1];
960 - }
961 - if ( ! empty($file_id) ) {
962 - $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
963 - }
964 - }
965 -
966 - // Get bot_id from form submission
967 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
968 -
969 - // Get bot-specific options and validate API key
970 - $bot_options = $this->get_bot_options($bot_id);
971 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
972 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
973 -
974 - if (strpos($selected_model, 'voyage') === 0) {
975 - $api_key = $options['voyage_api_key'] ?? '';
976 - $provider_name = 'Voyage AI';
977 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
978 - $api_key = $options['gemini_api_key'] ?? '';
979 - $provider_name = 'Google Gemini';
980 - } else {
981 - $api_key = $options['api_key'] ?? '';
982 - $provider_name = 'OpenAI';
983 - }
984 -
985 - if (empty($api_key)) {
986 - $error_message = sprintf(
987 - esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
988 - $provider_name
989 - );
990 - set_transient('mxchat_admin_notice_error', $error_message, 30);
991 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
992 - exit;
993 - }
994 -
995 - // Fetch URL — use browser-like headers so servers with bot protection don't block us
996 - $response = wp_remote_get($submitted_url, array(
997 - 'timeout' => 30,
998 - 'sslverify' => false,
999 - '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',
1000 - 'headers' => array(
1001 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1002 - 'Accept-Language' => 'en-US,en;q=0.9',
1003 - ),
1004 - ));
1005 -
1006 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1007 - $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1008 - set_transient('mxchat_admin_notice_error',
1009 - sprintf(
1010 - esc_html__('Failed to fetch the URL: %s', 'mxchat'),
1011 - esc_html($error_message)
1012 - ),
1013 - 30
1014 - );
1015 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1016 - exit;
1017 - }
1018 -
1019 - $content_type = wp_remote_retrieve_header($response, 'content-type');
1020 - $body_content = wp_remote_retrieve_body($response);
1021 -
1022 - if (empty($body_content)) {
1023 - set_transient('mxchat_admin_notice_error',
1024 - esc_html__('Empty response received from URL.', 'mxchat'),
1025 - 30
1026 - );
1027 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1028 - exit;
1029 - }
1030 -
1031 - // Handle PDF URL
1032 - if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
1033 - $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
1034 -
1035 - if ($result === 'queued') {
1036 - set_transient('mxchat_admin_notice_success',
1037 - esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
1038 - 30
1039 - );
1040 - } else {
1041 - set_transient('mxchat_admin_notice_error',
1042 - esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
1043 - 30
1044 - );
1045 - }
1046 -
1047 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1048 - exit;
1049 - }
1050 -
1051 - // Handle Sitemap XML
1052 - if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
1053 - libxml_use_internal_errors(true);
1054 - $xml = simplexml_load_string($body_content);
1055 - $xml_errors = libxml_get_errors();
1056 - libxml_clear_errors();
1057 -
1058 - if ($xml === false || !empty($xml_errors)) {
1059 - set_transient('mxchat_admin_notice_error',
1060 - esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
1061 - 30
1062 - );
1063 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1064 - exit;
1065 - }
1066 -
1067 - $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
1068 -
1069 - if ($result === 'queued') {
1070 - set_transient('mxchat_admin_notice_success',
1071 - esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1072 - 30
1073 - );
1074 - } else {
1075 - set_transient('mxchat_admin_notice_error',
1076 - esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
1077 - 30
1078 - );
1079 - }
1080 -
1081 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1082 - exit;
1083 - }
1084 -
1085 - // Handle Regular URL (single page)
1086 - $page_content = $this->mxchat_extract_main_content($body_content);
1087 - $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
1088 -
1089 - //error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
1090 - //error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
1091 - //error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
1092 - //error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
1093 -
1094 - if (empty($sanitized_content)) {
1095 - set_transient('mxchat_admin_notice_error',
1096 - esc_html__('No valid content found on the provided URL.', 'mxchat'),
1097 - 30
1098 - );
1099 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1100 - exit;
1101 - }
1102 -
1103 - // For single URLs, process immediately using submit_content_to_db
1104 - // This handles chunking automatically for large content
1105 - $db_result = MxChat_Utils::submit_content_to_db(
1106 - $sanitized_content,
1107 - $submitted_url,
1108 - $api_key,
1109 - null,
1110 - $bot_id,
1111 - 'url' // content_type
1112 - );
1113 -
1114 - if (is_wp_error($db_result)) {
1115 - $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1116 - set_transient('mxchat_admin_notice_error', $error_message, 30);
1117 - } else {
1118 - $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1119 - set_transient('mxchat_admin_notice_success', $success_message, 30);
1120 - }
1121 -
1122 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1123 - exit;
1124 -}
1125 -
1126 -
1127 -public function mxchat_get_single_url_status() {
1128 - $status = get_transient('mxchat_single_url_status');
1129 - if (!$status) {
1130 - return null;
1131 - }
1132 -
1133 - // Add human-readable time
1134 - if (isset($status['timestamp'])) {
1135 - $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1136 - }
1137 -
1138 - return $status;
1139 -}
1140 -
1141 -public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1142 - if (!current_user_can('manage_options')) {
1143 - return false;
1144 - }
1145 -
1146 - try {
1147 - $sitemap_url = esc_url_raw($sitemap_url);
1148 -
1149 - if (!$xml || !is_object($xml)) {
1150 - throw new Exception(__('Invalid XML object provided', 'mxchat'));
1151 - }
1152 -
1153 - // Get bot-specific embedding API for validation
1154 - $bot_options = $this->get_bot_options($bot_id);
1155 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1156 -
1157 - // Test the embedding API before processing
1158 - $test_phrase = "Test embedding generation for MxChat";
1159 - $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1160 -
1161 - if (is_string($test_result)) {
1162 - throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1163 - }
1164 -
1165 - if (!is_array($test_result)) {
1166 - throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1167 - }
1168 -
1169 - // Extract URLs from sitemap
1170 - $urls = array();
1171 - foreach ($xml->url as $url_element) {
1172 - $url = esc_url_raw((string)$url_element->loc);
1173 - if ($url) {
1174 - $urls[] = array('url' => $url);
1175 - }
1176 - }
1177 -
1178 - $total_urls = count($urls);
1179 -
1180 - if ($total_urls < 1) {
1181 - throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1182 - }
1183 -
1184 - // Create unique queue ID
1185 - $queue_id = 'sitemap_' . md5($sitemap_url . time());
1186 -
1187 - // Add URLs to queue
1188 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1189 -
1190 - if ($queued_count === 0) {
1191 - throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1192 - }
1193 -
1194 - // Store queue metadata
1195 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1196 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1197 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1198 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1199 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1200 -
1201 - // Store queue ID in transient for status tracking
1202 - set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
1203 - set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
1204 -
1205 - return 'queued';
1206 -
1207 - } catch (Exception $e) {
1208 - $error_message = $e->getMessage();
1209 - //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
1210 -
1211 - return $error_message;
1212 - }
1213 -
1214 -}
1215 -
1216 -/**
1217 - * Remove shortcode tags but preserve the content inside them
1218 - * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
1219 - *
1220 - * @param string $content The content containing shortcodes
1221 - * @return string Content with shortcode tags removed but inner content preserved
1222 - */
1223 -private function strip_shortcode_tags_preserve_content($content) {
1224 - // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
1225 - // Content between tags is inherently preserved since only brackets are targeted
1226 - $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
1227 - return ($result !== null) ? $result : $content;
1228 -}
1229 -
1230 -public function mxchat_sanitize_content_for_api($content) {
1231 - //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
1232 -
1233 - // Remove shortcode tags but PRESERVE content inside them
1234 - $content = $this->strip_shortcode_tags_preserve_content($content);
1235 -
1236 - // Remove script, style tags, and HTML comments
1237 - $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
1238 - $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
1239 - $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
1240 -
1241 - // Remove all HTML tags and decode HTML entities
1242 - $content = wp_strip_all_tags($content);
1243 - $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
1244 -
1245 - // Normalize whitespace but preserve paragraph breaks
1246 - // First, normalize line endings to \n
1247 - $content = str_replace(["\r\n", "\r"], "\n", $content);
1248 - // Replace multiple spaces/tabs with single space, but preserve newlines
1249 - $content = preg_replace('/[ \t]+/', ' ', $content);
1250 - // Replace 3+ newlines with 2 newlines (max 2 blank lines)
1251 - $content = preg_replace('/\n{3,}/', "\n\n", $content);
1252 - // Trim each line
1253 - $lines = explode("\n", $content);
1254 - $lines = array_map('trim', $lines);
1255 - $content = implode("\n", $lines);
1256 - // Final trim
1257 - $content = trim($content);
1258 -
1259 - // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
1260 - $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
1261 -
1262 - // Remove NULL bytes which can cause database errors
1263 - $content = str_replace("\0", "", $content);
1264 -
1265 - // Ensure valid UTF-8 encoding
1266 - $content = wp_check_invalid_utf8($content);
1267 -
1268 - // Remove any extremely long strings without spaces (often garbage)
1269 - $content = preg_replace('/\S{300,}/', ' ', $content);
1270 -
1271 - // Replace problematic characters that often cause database issues
1272 - $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
1273 -
1274 - // Replace any remaining potentially problematic characters with spaces
1275 - // BUT preserve newlines by temporarily replacing them
1276 - $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
1277 - $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
1278 - $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
1279 -
1280 - // Limit to reasonable length if needed
1281 - $max_length = 65000; // Just under MySQL TEXT field limit
1282 - if (strlen($content) > $max_length) {
1283 - $content = substr($content, 0, $max_length);
1284 - }
1285 -
1286 - //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
1287 - return $content;
1288 -}
1289 -public function mxchat_extract_main_content($html) {
1290 - if (empty($html)) {
1291 - return '';
1292 - }
1293 - try {
1294 - $dom = new DOMDocument;
1295 - libxml_use_internal_errors(true); // Suppress HTML parsing errors
1296 - @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
1297 - $xpath = new DOMXPath($dom);
1298 -
1299 - // For debugging purposes
1300 - $debugEnabled = true; // Set to true to enable debugging output
1301 - $debug = function($message) use ($debugEnabled) {
1302 - if ($debugEnabled) {
1303 - //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
1304 - }
1305 - };
1306 -
1307 - // Direct targeting for Gerow theme posts
1308 - $post_text = $xpath->query('//div[contains(@class, "post-text")]');
1309 - if ($post_text && $post_text->length > 0) {
1310 - $debug("Found post-text directly");
1311 - $content = '';
1312 - foreach ($post_text as $node) {
1313 - $content .= $dom->saveHTML($node);
1314 - }
1315 - if (!empty($content)) {
1316 - $debug("Returning post-text content");
1317 - return $content;
1318 - }
1319 - }
1320 -
1321 - // Try to get the blog details content which contains the post-text
1322 - $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
1323 - if ($blog_details && $blog_details->length > 0) {
1324 - $debug("Found blog-details-content");
1325 - $content = '';
1326 - foreach ($blog_details as $node) {
1327 - $content .= $dom->saveHTML($node);
1328 - }
1329 - if (!empty($content)) {
1330 - $debug("Returning blog-details-content");
1331 - return $content;
1332 - }
1333 - }
1334 -
1335 - // Try to get the article which contains the blog details
1336 - $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
1337 - if ($article && $article->length > 0) {
1338 - $debug("Found article with blog-details-wrap");
1339 - $content = '';
1340 - foreach ($article as $node) {
1341 - $content .= $dom->saveHTML($node);
1342 - }
1343 - if (!empty($content)) {
1344 - $debug("Returning article content");
1345 - return $content;
1346 - }
1347 - }
1348 -
1349 - // Try even broader with the blog-item-wrap
1350 - $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
1351 - if ($blog_item && $blog_item->length > 0) {
1352 - $debug("Found blog-item-wrap");
1353 - $content = '';
1354 - foreach ($blog_item as $node) {
1355 - $content .= $dom->saveHTML($node);
1356 - }
1357 - if (!empty($content)) {
1358 - $debug("Returning blog-item-wrap content");
1359 - return $content;
1360 - }
1361 - }
1362 -
1363 - // Specific Gerow theme path
1364 - $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
1365 - if ($gerow_path && $gerow_path->length > 0) {
1366 - $debug("Found Gerow theme path to post-text");
1367 - $content = '';
1368 - foreach ($gerow_path as $node) {
1369 - $content .= $dom->saveHTML($node);
1370 - }
1371 - if (!empty($content)) {
1372 - $debug("Returning Gerow post-text content");
1373 - return $content;
1374 - }
1375 - }
1376 -
1377 - // Generic blog post selectors
1378 - $selectors = [
1379 - // Blog post specific selectors
1380 - '//div[contains(@class, "post-text")]',
1381 - '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
1382 - '//div[contains(@class, "blog-details-content")]',
1383 - '//article[contains(@class, "blog-details-wrap")]',
1384 - '//div[contains(@class, "entry-content")]',
1385 - '//div[contains(@class, "blog-content")]',
1386 - '//div[contains(@class, "blog-item-wrap")]',
1387 -
1388 - // More general content selectors
1389 - '//div[contains(@class, "page__content")]',
1390 - '//div[contains(@class, "elementor-widget-container")]',
1391 - '//div[contains(@class, "elementor-text-editor")]',
1392 - '//div[contains(@class, "elementor-widget-text-editor")]',
1393 - '//*[contains(@class, "entry-content")]',
1394 - '//*[contains(@class, "post-content")]',
1395 - '//*[contains(@class, "article-content")]',
1396 - '//*[@id="content"]',
1397 - '//*[@id="main-content"]',
1398 - '//section[contains(@class, "blog-area")]',
1399 - '//article',
1400 - '//main',
1401 - '//div[contains(@class, "content")]'
1402 - ];
1403 -
1404 - // First handle Elementor content - get only leaf widget containers to avoid duplicates
1405 - $debug("Checking for Elementor content");
1406 - // Get widget containers that are direct children of widgets (not nested inside other widget containers)
1407 - $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
1408 - if ($elementor_widgets && $elementor_widgets->length > 0) {
1409 - $debug("Found Elementor widgets");
1410 - $seen_content = array(); // Track seen content to avoid duplicates
1411 - $combined_content = '';
1412 - foreach ($elementor_widgets as $widget) {
1413 - $widget_content = $dom->saveHTML($widget);
1414 - if (!empty($widget_content)) {
1415 - // Create a hash of the content to detect duplicates
1416 - $content_hash = md5($widget_content);
1417 - if (!isset($seen_content[$content_hash])) {
1418 - $seen_content[$content_hash] = true;
1419 - $combined_content .= $widget_content;
1420 - }
1421 - }
1422 - }
1423 - if (!empty($combined_content)) {
1424 - $debug("Returning Elementor content");
1425 - return $combined_content;
1426 - }
1427 - }
1428 -
1429 - // Try standard selectors one by one
1430 - foreach ($selectors as $selector) {
1431 - $debug("Trying selector: " . $selector);
1432 - $nodes = $xpath->query($selector);
1433 - if ($nodes && $nodes->length > 0) {
1434 - $debug("Found " . $nodes->length . " matches for selector: " . $selector);
1435 - // Only take the FIRST matching node to avoid duplicate content
1436 - // (pages often have nested or multiple containers with same class)
1437 - $content = $dom->saveHTML($nodes->item(0));
1438 - if (!empty($content)) {
1439 - $debug("Returning content from selector: " . $selector . " (first match only)");
1440 - return $content;
1441 - }
1442 - }
1443 - }
1444 -
1445 - // Manual regex fallback for post-text if DOM methods fail
1446 - $debug("Trying regex fallback");
1447 - if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
1448 - $debug("Found post-text via regex");
1449 - return '<div class="post-text">' . $matches[1] . '</div>';
1450 - }
1451 -
1452 - // Try to extract the blog section as a whole
1453 - $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
1454 - if ($blog_section && $blog_section->length > 0) {
1455 - $debug("Found blog-area section");
1456 - $content = '';
1457 - foreach ($blog_section as $node) {
1458 - $content .= $dom->saveHTML($node);
1459 - }
1460 - if (!empty($content)) {
1461 - $debug("Returning blog-area section content");
1462 - return $content;
1463 - }
1464 - }
1465 -
1466 - // Generic container selectors for non-CMS sites (like .asp pages)
1467 - $debug("Trying generic container selectors");
1468 - $generic_selectors = [
1469 - '//div[@id="main"]',
1470 - '//div[@id="wrapper"]',
1471 - '//div[@id="page"]',
1472 - '//div[@id="site-content"]',
1473 - '//div[contains(@class, "main-content")]',
1474 - '//div[contains(@class, "page-content")]',
1475 - '//div[contains(@class, "site-content")]',
1476 - ];
1477 -
1478 - foreach ($generic_selectors as $selector) {
1479 - $debug("Trying generic selector: " . $selector);
1480 - $nodes = $xpath->query($selector);
1481 - if ($nodes && $nodes->length > 0) {
1482 - $content = $dom->saveHTML($nodes->item(0));
1483 - if (!empty($content)) {
1484 - $debug("Returning content from generic selector: " . $selector);
1485 - return $content;
1486 - }
1487 - }
1488 - }
1489 -
1490 - // Paragraph-based content detection - find regions with substantial text
1491 - $debug("Trying paragraph-based content detection");
1492 - $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
1493 - if ($paragraphs && $paragraphs->length >= 3) {
1494 - $debug("Found " . $paragraphs->length . " substantial paragraphs");
1495 - // Collect all substantial paragraphs and their content
1496 - $paragraph_content = '';
1497 - foreach ($paragraphs as $p) {
1498 - $paragraph_content .= $dom->saveHTML($p) . "\n";
1499 - }
1500 - if (!empty($paragraph_content)) {
1501 - $debug("Returning paragraph-based content");
1502 - return $paragraph_content;
1503 - }
1504 - }
1505 -
1506 - // Improved body fallback - strip nav/header/footer elements first
1507 - $debug("Using improved body fallback");
1508 - $body = $dom->getElementsByTagName('body');
1509 - if ($body->length > 0) {
1510 - // Clone the body to avoid modifying the original DOM
1511 - $body_clone = $body->item(0)->cloneNode(true);
1512 -
1513 - // Remove common non-content elements by tag name
1514 - $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
1515 - foreach ($remove_tags as $tag) {
1516 - $elements = $body_clone->getElementsByTagName($tag);
1517 - // Iterate backwards to safely remove elements
1518 - for ($i = $elements->length - 1; $i >= 0; $i--) {
1519 - $el = $elements->item($i);
1520 - if ($el && $el->parentNode) {
1521 - $el->parentNode->removeChild($el);
1522 - }
1523 - }
1524 - }
1525 -
1526 - // Remove elements with common non-content class names using XPath on the cloned body
1527 - $temp_dom = new DOMDocument();
1528 - @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
1529 - $temp_xpath = new DOMXPath($temp_dom);
1530 -
1531 - $remove_class_patterns = [
1532 - '//*[contains(@class, "nav")]',
1533 - '//*[contains(@class, "menu")]',
1534 - '//*[contains(@class, "sidebar")]',
1535 - '//*[contains(@class, "footer")]',
1536 - '//*[contains(@class, "header")]',
1537 - '//*[contains(@id, "nav")]',
1538 - '//*[contains(@id, "menu")]',
1539 - '//*[contains(@id, "sidebar")]',
1540 - '//*[contains(@id, "footer")]',
1541 - '//*[contains(@id, "header")]',
1542 - ];
1543 -
1544 - foreach ($remove_class_patterns as $pattern) {
1545 - $elements = $temp_xpath->query($pattern);
1546 - if ($elements) {
1547 - for ($i = $elements->length - 1; $i >= 0; $i--) {
1548 - $el = $elements->item($i);
1549 - if ($el && $el->parentNode) {
1550 - $el->parentNode->removeChild($el);
1551 - }
1552 - }
1553 - }
1554 - }
1555 -
1556 - $cleaned_content = $temp_dom->saveHTML();
1557 - if (!empty($cleaned_content)) {
1558 - $debug("Returning cleaned body content");
1559 - return $cleaned_content;
1560 - }
1561 - }
1562 -
1563 - // Last resort: return the original HTML
1564 - $debug("Returning original HTML");
1565 - return $html;
1566 - } catch (Exception $e) {
1567 - //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
1568 - return $html; // Return original HTML if parsing fails
1569 - } finally {
1570 - libxml_clear_errors();
1571 - }
1572 -}
1573 -public function mxchat_get_sitemap_processing_status($sitemap_url) {
1574 - $sitemap_url = esc_url_raw($sitemap_url);
1575 - $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
1576 - $status = get_transient($status_key);
1577 -
1578 - if (!$status || !is_array($status)) {
1579 - return false;
1580 - }
1581 -
1582 - // Auto-complete check: if all URLs are processed but status isn't complete
1583 - if (isset($status['processed_urls']) && isset($status['total_urls']) &&
1584 - $status['processed_urls'] >= $status['total_urls'] &&
1585 - isset($status['status']) && $status['status'] !== 'complete' &&
1586 - $status['status'] !== 'error') {
1587 -
1588 - // Mark as complete
1589 - $status['status'] = 'complete';
1590 - $status['processed_urls'] = $status['total_urls']; // Ensure exact match
1591 -
1592 - // Update the transient with the corrected status
1593 - set_transient($status_key, $status, DAY_IN_SECONDS);
1594 - }
1595 -
1596 - return array(
1597 - 'total_urls' => absint($status['total_urls']),
1598 - 'processed_urls' => absint($status['processed_urls']),
1599 - 'failed_urls' => absint($status['failed_urls'] ?? 0),
1600 - 'percentage' => ($status['total_urls'] > 0)
1601 - ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
1602 - : 0,
1603 - 'status' => sanitize_text_field($status['status']),
1604 - 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1605 - 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
1606 - 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
1607 - 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
1608 - );
1609 -}
1610 -
1611 -public function mxchat_ajax_get_status_updates() {
1612 - try {
1613 - // Verify the request
1614 - check_ajax_referer('mxchat_status_nonce', 'nonce');
1615 -
1616 - // Get active queue IDs
1617 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1618 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1619 -
1620 - $sitemap_status = false;
1621 - $pdf_status = false;
1622 -
1623 - // Get sitemap queue status
1624 - if ($sitemap_queue_id) {
1625 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1626 - }
1627 -
1628 - // Get PDF queue status
1629 - if ($pdf_queue_id) {
1630 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1631 - }
1632 -
1633 - $is_active_processing =
1634 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1635 - ($pdf_status && $pdf_status['status'] === 'processing');
1636 -
1637 - // Return JSON response with the status data
1638 - wp_send_json(array(
1639 - 'pdf_status' => $pdf_status,
1640 - 'sitemap_status' => $sitemap_status,
1641 - 'is_processing' => $is_active_processing,
1642 - 'sitemap_queue_id' => $sitemap_queue_id,
1643 - 'pdf_queue_id' => $pdf_queue_id
1644 - ));
1645 -
1646 - } catch (Exception $e) {
1647 - //error_log('MxChat Status Update Error: ' . $e->getMessage());
1648 -
1649 - wp_send_json_error(array(
1650 - 'message' => 'Error getting status updates: ' . $e->getMessage(),
1651 - 'status' => 'error'
1652 - ));
1653 - }
1654 -}
1655 -
1656 -/**
1657 - * Helper function to get queue status data
1658 - */
1659 -private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
1660 - global $wpdb;
1661 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
1662 -
1663 - // Get counts by status
1664 - $counts = $wpdb->get_results($wpdb->prepare(
1665 - "SELECT status, COUNT(*) as count
1666 - FROM $table_name
1667 - WHERE queue_id = %s
1668 - GROUP BY status",
1669 - $queue_id
1670 - ), OBJECT_K);
1671 -
1672 - $total = 0;
1673 - $completed = 0;
1674 - $failed = 0;
1675 - $processing = 0;
1676 - $pending = 0;
1677 -
1678 - foreach ($counts as $status => $data) {
1679 - $count = absint($data->count);
1680 - $total += $count;
1681 -
1682 - switch ($status) {
1683 - case 'completed':
1684 - $completed = $count;
1685 - break;
1686 - case 'failed':
1687 - $failed = $count;
1688 - break;
1689 - case 'processing':
1690 - $processing = $count;
1691 - break;
1692 - case 'pending':
1693 - $pending = $count;
1694 - break;
1695 - }
1696 - }
1697 -
1698 - if ($total === 0) {
1699 - return false;
1700 - }
1701 -
1702 - // Calculate percentage
1703 - $percentage = round((($completed + $failed) / $total) * 100);
1704 -
1705 - // Get failed items details (limit to 50)
1706 - $failed_items = array();
1707 - if ($failed > 0) {
1708 - $failed_results = $wpdb->get_results($wpdb->prepare(
1709 - "SELECT item_type, item_data, error_message, attempts, completed_at
1710 - FROM $table_name
1711 - WHERE queue_id = %s
1712 - AND status = 'failed'
1713 - AND attempts >= max_attempts
1714 - ORDER BY id DESC
1715 - LIMIT 50",
1716 - $queue_id
1717 - ));
1718 -
1719 - foreach ($failed_results as $item) {
1720 - $data = json_decode($item->item_data, true);
1721 - $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
1722 -
1723 - $failed_items[] = array(
1724 - 'url' => $url,
1725 - 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
1726 - 'error' => $item->error_message,
1727 - 'retries' => $item->attempts,
1728 - 'time' => strtotime($item->completed_at)
1729 - );
1730 - }
1731 - }
1732 -
1733 - // Get queue metadata
1734 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
1735 -
1736 - // Determine if queue is complete
1737 - $is_complete = ($pending === 0 && $processing === 0);
1738 -
1739 - // Get last update time
1740 - $last_update = $wpdb->get_var($wpdb->prepare(
1741 - "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
1742 - FROM $table_name
1743 - WHERE queue_id = %s",
1744 - $queue_id
1745 - ));
1746 -
1747 - $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
1748 -
1749 - // Format based on type
1750 - if ($type === 'pdf') {
1751 - return array(
1752 - 'total_pages' => $total,
1753 - 'processed_pages' => $completed + $failed,
1754 - 'failed_pages' => $failed,
1755 - 'percentage' => $percentage,
1756 - 'status' => $is_complete ? 'complete' : 'processing',
1757 - 'last_update' => $last_update_text,
1758 - 'failed_pages_list' => $failed_items,
1759 - 'pdf_url' => $source_url,
1760 - 'queue_id' => $queue_id
1761 - );
1762 - } else {
1763 - return array(
1764 - 'total_urls' => $total,
1765 - 'processed_urls' => $completed + $failed,
1766 - 'failed_urls' => $failed,
1767 - 'percentage' => $percentage,
1768 - 'status' => $is_complete ? 'complete' : 'processing',
1769 - 'last_update' => $last_update_text,
1770 - 'failed_urls_list' => $failed_items,
1771 - 'sitemap_url' => $source_url,
1772 - 'queue_id' => $queue_id
1773 - );
1774 - }
1775 -}
1776 -
1777 -/**
1778 - * Public method to get processing status for both sitemap and PDF queues
1779 - * Used by admin pages to display processing status
1780 - *
1781 - * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
1782 - */
1783 -public function mxchat_get_processing_statuses() {
1784 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1785 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1786 -
1787 - $sitemap_status = false;
1788 - $pdf_status = false;
1789 -
1790 - if ($sitemap_queue_id) {
1791 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1792 - }
1793 -
1794 - if ($pdf_queue_id) {
1795 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1796 - }
1797 -
1798 - $is_processing =
1799 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1800 - ($pdf_status && $pdf_status['status'] === 'processing');
1801 -
1802 - return array(
1803 - 'sitemap_status' => $sitemap_status,
1804 - 'pdf_status' => $pdf_status,
1805 - 'is_processing' => $is_processing
1806 - );
1807 -}
1808 -
1809 -/**
1810 - * AJAX handler to get recent knowledge entries for real-time table updates
1811 - * UPDATED: Now supports both WordPress DB and Pinecone data sources
1812 - */
1813 -public function ajax_mxchat_get_recent_entries() {
1814 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
1815 -
1816 - if (!current_user_can('manage_options')) {
1817 - wp_send_json_error(array('message' => 'Unauthorized'));
1818 - return;
1819 - }
1820 -
1821 - global $wpdb;
1822 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1823 -
1824 - // Get parameters
1825 - $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
1826 - $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
1827 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1828 -
1829 - // Check if Pinecone is enabled for this bot
1830 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
1831 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
1832 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1833 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
1834 -
1835 - if ($use_pinecone && $has_pinecone_api) {
1836 - // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
1837 - // Use mxchat_fetch_pinecone_records which returns total_unique_entries
1838 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
1839 - $total_count = $records['total'] ?? 0;
1840 -
1841 - // For Pinecone, we don't return individual entries during polling
1842 - // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
1843 - // We just return the updated count
1844 - wp_send_json_success(array(
1845 - 'entries' => array(),
1846 - 'total_count' => absint($total_count),
1847 - 'max_id' => $last_id,
1848 - 'data_source' => 'pinecone'
1849 - ));
1850 - return;
1851 - }
1852 -
1853 - // WORDPRESS DB DATA SOURCE
1854 - // Build query to get entries newer than last_id
1855 - $where_clauses = array('1=1');
1856 - $where_values = array();
1857 -
1858 - if ($last_id > 0) {
1859 - $where_clauses[] = 'id > %d';
1860 - $where_values[] = $last_id;
1861 - }
1862 -
1863 - // Note: WordPress DB table doesn't have bot_id column
1864 - // Multi-bot filtering is handled via Pinecone namespaces
1865 -
1866 - $where_sql = implode(' AND ', $where_clauses);
1867 -
1868 - // Get recent entries
1869 - $query = "SELECT id, article_content, source_url, timestamp
1870 - FROM $table_name
1871 - WHERE $where_sql
1872 - ORDER BY id DESC
1873 - LIMIT %d";
1874 -
1875 - $where_values[] = $limit;
1876 -
1877 - $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
1878 -
1879 - // Get total count of GROUPED entries (by source_url) - matches pagination display
1880 - // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
1881 - $total_count = $wpdb->get_var(
1882 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1883 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
1884 - );
1885 -
1886 - // Format entries for response
1887 - $formatted_entries = array();
1888 - $preview_length = 150;
1889 - foreach ($entries as $entry) {
1890 - // Parse chunk metadata using the proper chunker method (same as initial page load)
1891 - if (class_exists('MxChat_Chunker')) {
1892 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
1893 - $display_content = $chunk_meta['text'];
1894 - $chunk_metadata = $chunk_meta['metadata'];
1895 - } else {
1896 - $display_content = $entry->article_content;
1897 - $chunk_metadata = array();
1898 - }
1899 -
1900 - $content_preview = mb_strlen($display_content) > $preview_length
1901 - ? mb_substr($display_content, 0, $preview_length) . '...'
1902 - : $display_content;
1903 -
1904 - $formatted_entries[] = array(
1905 - 'id' => $entry->id,
1906 - 'preview' => esc_html($content_preview),
1907 - 'full_content' => wp_kses_post(wpautop($display_content)),
1908 - 'content_length' => mb_strlen($display_content),
1909 - 'preview_length' => $preview_length,
1910 - 'source_url' => $entry->source_url,
1911 - 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
1912 - 'chunk_metadata' => $chunk_metadata,
1913 - 'bot_id' => $entry->bot_id ?? 'default',
1914 - 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
1915 - 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
1916 - );
1917 - }
1918 -
1919 - wp_send_json_success(array(
1920 - 'entries' => $formatted_entries,
1921 - 'total_count' => absint($total_count),
1922 - 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
1923 - 'data_source' => 'wordpress'
1924 - ));
1925 -}
1926 -
1927 -/**
1928 - * Get Pinecone total count from stats API
1929 - * Helper function for ajax_mxchat_get_recent_entries
1930 - */
1931 -private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
1932 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1933 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1934 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1935 -
1936 - if (empty($api_key) || empty($host)) {
1937 - return 0;
1938 - }
1939 -
1940 - try {
1941 - $stats_url = "https://{$host}/describe_index_stats";
1942 -
1943 - $response = wp_remote_post($stats_url, array(
1944 - 'headers' => array(
1945 - 'Api-Key' => $api_key,
1946 - 'Content-Type' => 'application/json'
1947 - ),
1948 - 'body' => '{}',
1949 - 'timeout' => 10
1950 - ));
1951 -
1952 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1953 - $body = wp_remote_retrieve_body($response);
1954 - $stats_data = json_decode($body, true);
1955 -
1956 - // If namespace is specified, get count from that specific namespace
1957 - if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
1958 - return intval($stats_data['namespaces'][$namespace]['vectorCount']);
1959 - }
1960 -
1961 - // If no namespace specified or namespace not found in response, use total
1962 - return intval($stats_data['totalVectorCount'] ?? 0);
1963 - }
1964 -
1965 - return 0;
1966 -
1967 - } catch (Exception $e) {
1968 - return 0;
1969 - }
1970 -}
1971 -
1972 -/**
1973 - * AJAX handler to refresh Pinecone entries table via AJAX
1974 - * Returns the table HTML for updating the UI without a full page reload
1975 - */
1976 -public function ajax_mxchat_refresh_pinecone_entries() {
1977 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
1978 -
1979 - if (!current_user_can('manage_options')) {
1980 - wp_send_json_error(array('message' => 'Unauthorized'));
1981 - return;
1982 - }
1983 -
1984 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1985 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
1986 - $per_page = 25;
1987 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
1988 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
1989 -
1990 - // Get Pinecone manager and options
1991 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
1992 - if (!$pinecone_manager) {
1993 - wp_send_json_error(array('message' => 'Pinecone manager not available'));
1994 - return;
1995 - }
1996 -
1997 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
1998 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1999 - $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2000 -
2001 - if (!$use_pinecone || empty($pinecone_api_key)) {
2002 - wp_send_json_error(array('message' => 'Pinecone not configured'));
2003 - return;
2004 - }
2005 -
2006 - // Fetch records from Pinecone
2007 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
2008 - $prompts = $records['data'] ?? array();
2009 - $total_records = $records['total'] ?? 0;
2010 -
2011 - // Preprocess Pinecone records — set chunk_metadata and display_content
2012 - // (matches admin-knowledge-page.php preprocessing)
2013 - foreach ($prompts as $prompt) {
2014 - if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
2015 - $prompt->chunk_metadata = array(
2016 - 'chunk_index' => intval($prompt->chunk_index),
2017 - 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
2018 - 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
2019 - 'source_url' => $prompt->source_url ?? ''
2020 - );
2021 - $prompt->display_content = $prompt->article_content;
2022 - } else {
2023 - $prompt->chunk_metadata = array();
2024 - $prompt->display_content = $prompt->article_content ?? '';
2025 - }
2026 - }
2027 -
2028 - // Group prompts by source_url
2029 - $grouped_prompts = array();
2030 - foreach ($prompts as $prompt) {
2031 - $source_url = '';
2032 - if (!empty($prompt->chunk_metadata['source_url'])) {
2033 - $source_url = $prompt->chunk_metadata['source_url'];
2034 - } elseif (!empty($prompt->source_url)) {
2035 - $source_url = $prompt->source_url;
2036 - }
2037 -
2038 - if (!empty($source_url)) {
2039 - if (!isset($grouped_prompts[$source_url])) {
2040 - $grouped_prompts[$source_url] = array();
2041 - }
2042 - $grouped_prompts[$source_url][] = $prompt;
2043 - } else {
2044 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2045 - }
2046 - }
2047 -
2048 - // Sort each group by chunk_index
2049 - foreach ($grouped_prompts as $source_url => &$group) {
2050 - usort($group, function($a, $b) {
2051 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2052 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2053 - return $index_a - $index_b;
2054 - });
2055 - }
2056 - unset($group);
2057 -
2058 - // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
2059 - ob_start();
2060 - $display_index = 0;
2061 - $current_page = $page;
2062 - $data_source = 'pinecone';
2063 - $current_bot_id = $bot_id;
2064 - $preview_length = 150;
2065 -
2066 - if (empty($grouped_prompts)) {
2067 - echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2068 - esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
2069 - echo '</td></tr>';
2070 - } else {
2071 - foreach ($grouped_prompts as $source_url => $group) {
2072 - $chunk_count = count($group);
2073 - $first_prompt = $group[0];
2074 - $display_index++;
2075 -
2076 - if ($chunk_count > 1) {
2077 - // Multiple chunks - show grouped row with expand button
2078 - $group_id = 'group-' . md5($source_url);
2079 - ?>
2080 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2081 - class="mxchat-chunk-group-header"
2082 - data-source="<?php echo esc_attr($data_source); ?>"
2083 - data-group-id="<?php echo esc_attr($group_id); ?>"
2084 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2085 - <td style="padding: 12px 16px; text-align: center;">
2086 - <input type="checkbox"
2087 - class="mxchat-entry-checkbox"
2088 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2089 - data-source="<?php echo esc_attr($data_source); ?>"
2090 - data-source-url="<?php echo esc_attr($source_url); ?>"
2091 - data-is-group="true"
2092 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2093 - </td>
2094 - <td style="padding: 12px 16px; font-size: 13px;">
2095 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2096 - </td>
2097 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2098 - <div class="mxchat-chunk-group-info">
2099 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2100 - <span class="dashicons dashicons-arrow-right-alt2"></span>
2101 - </button>
2102 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2103 - <span class="mxchat-chunk-preview">
2104 - <?php
2105 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
2106 - $content_preview = mb_substr($parent_content, 0, 100);
2107 - echo esc_html($content_preview . '...');
2108 - ?>
2109 - </span>
2110 - </div>
2111 - </td>
2112 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2113 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2114 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2115 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2116 - <?php esc_html_e('View Source', 'mxchat'); ?>
2117 - </a>
2118 - <?php else : ?>
2119 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2120 - <?php endif; ?>
2121 - </td>
2122 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2123 - <?php if ($data_source !== 'pinecone') : ?>
2124 - <button type="button"
2125 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2126 - data-source-url="<?php echo esc_attr($source_url); ?>"
2127 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2128 - data-data-source="<?php echo esc_attr($data_source); ?>"
2129 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2130 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2131 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2132 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2133 - </button>
2134 - <?php endif; ?>
2135 - <button type="button"
2136 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2137 - data-source-url="<?php echo esc_attr($source_url); ?>"
2138 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2139 - data-data-source="<?php echo esc_attr($data_source); ?>"
2140 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2141 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2142 - style="color: var(--mxch-error);"
2143 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2144 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2145 - </button>
2146 - </td>
2147 - </tr>
2148 - <?php
2149 - // Render hidden chunk rows
2150 - foreach ($group as $chunk_index => $chunk) {
2151 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2152 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2153 - $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
2154 - $content_preview = mb_strlen($content) > $preview_length
2155 - ? mb_substr($content, 0, $preview_length) . '...'
2156 - : $content;
2157 - ?>
2158 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2159 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2160 - data-source="<?php echo esc_attr($data_source); ?>"
2161 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2162 - <td style="padding: 12px 16px; text-align: center;">
2163 - <!-- Checkbox column placeholder for chunks (managed by group) -->
2164 - </td>
2165 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2166 - <!-- Hidden ID column for chunks -->
2167 - </td>
2168 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2169 - <div class="mxchat-accordion-wrapper">
2170 - <div class="mxchat-content-preview">
2171 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2172 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2173 - </span>
2174 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2175 - <?php if (mb_strlen($content) > $preview_length) : ?>
2176 - <button class="mxchat-expand-toggle" type="button">
2177 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2178 - </button>
2179 - <?php endif; ?>
2180 - </div>
2181 - <div class="mxchat-content-full" style="display: none;">
2182 - <div class="content-view">
2183 - <?php
2184 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2185 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2186 - echo wp_kses_post(wpautop($content));
2187 - echo '</div>';
2188 - } else {
2189 - echo wp_kses_post(wpautop($content));
2190 - }
2191 - ?>
2192 - </div>
2193 - </div>
2194 - </div>
2195 - </td>
2196 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2197 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2198 - </td>
2199 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2200 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2201 - </td>
2202 - </tr>
2203 - <?php
2204 - }
2205 - } else {
2206 - // Single entry - display normally with accordion
2207 - $prompt = $first_prompt;
2208 - $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
2209 - $content_preview = mb_strlen($content) > $preview_length
2210 - ? mb_substr($content, 0, $preview_length) . '...'
2211 - : $content;
2212 - ?>
2213 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2214 - data-source="<?php echo esc_attr($data_source); ?>"
2215 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2216 - <td style="padding: 12px 16px; text-align: center;">
2217 - <input type="checkbox"
2218 - class="mxchat-entry-checkbox"
2219 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2220 - data-source="<?php echo esc_attr($data_source); ?>"
2221 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
2222 - data-is-group="false"
2223 - data-chunk-count="1">
2224 - </td>
2225 - <td style="padding: 12px 16px; font-size: 13px;">
2226 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2227 - </td>
2228 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2229 - <div class="mxchat-accordion-wrapper">
2230 - <div class="mxchat-content-preview">
2231 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2232 - <?php if (mb_strlen($content) > $preview_length) : ?>
2233 - <button class="mxchat-expand-toggle" type="button">
2234 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2235 - </button>
2236 - <?php endif; ?>
2237 - </div>
2238 - <div class="mxchat-content-full" style="display: none;">
2239 - <div class="content-view">
2240 - <?php
2241 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2242 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2243 - echo wp_kses_post(wpautop($content));
2244 - echo '</div>';
2245 - } else {
2246 - echo wp_kses_post(wpautop($content));
2247 - }
2248 - ?>
2249 - </div>
2250 - </div>
2251 - </div>
2252 - </td>
2253 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2254 - <?php
2255 - $actual_source = $source_url;
2256 - if (strpos($source_url, '_ungrouped_') === 0) {
2257 - $actual_source = $prompt->source_url ?? '';
2258 - }
2259 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2260 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2261 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2262 - <?php esc_html_e('View', 'mxchat'); ?>
2263 - </a>
2264 - <?php else : ?>
2265 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2266 - <?php endif; ?>
2267 - </td>
2268 - <td style="padding: 12px 16px;">
2269 - <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);">
2270 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2271 - </button>
2272 - </td>
2273 - </tr>
2274 - <?php
2275 - }
2276 - }
2277 - }
2278 - $html = ob_get_clean();
2279 -
2280 - // Generate pagination HTML for Pinecone
2281 - $total_pages = ceil($total_records / $per_page);
2282 - $pagination_html = '';
2283 - if ($total_pages > 1) {
2284 - $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) . '">';
2285 -
2286 - // Previous button
2287 - if ($page > 1) {
2288 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2289 - }
2290 -
2291 - // Page numbers
2292 - $start_page = max(1, $page - 2);
2293 - $end_page = min($total_pages, $page + 2);
2294 -
2295 - if ($start_page > 1) {
2296 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2297 - if ($start_page > 2) {
2298 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2299 - }
2300 - }
2301 -
2302 - for ($i = $start_page; $i <= $end_page; $i++) {
2303 - if ($i == $page) {
2304 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2305 - } else {
2306 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2307 - }
2308 - }
2309 -
2310 - if ($end_page < $total_pages) {
2311 - if ($end_page < $total_pages - 1) {
2312 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2313 - }
2314 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2315 - }
2316 -
2317 - // Next button
2318 - if ($page < $total_pages) {
2319 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2320 - }
2321 -
2322 - $pagination_html .= '</div>';
2323 - }
2324 -
2325 - wp_send_json_success(array(
2326 - 'html' => $html,
2327 - 'pagination_html' => $pagination_html,
2328 - 'total_count' => $total_records,
2329 - 'total_pages' => $total_pages,
2330 - 'page' => $page,
2331 - 'per_page' => $per_page,
2332 - 'data_source' => 'pinecone'
2333 - ));
2334 -}
2335 -
2336 -/**
2337 - * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
2338 - * Returns paginated entries without requiring a full page reload
2339 - */
2340 -public function ajax_mxchat_paginate_entries() {
2341 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2342 -
2343 - if (!current_user_can('manage_options')) {
2344 - wp_send_json_error(array('message' => 'Unauthorized'));
2345 - return;
2346 - }
2347 -
2348 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2349 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2350 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2351 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2352 - $per_page = 25;
2353 -
2354 - // Check if Pinecone is enabled for this bot
2355 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2356 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2357 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2358 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2359 -
2360 - if ($use_pinecone && $has_pinecone_api) {
2361 - // Delegate to Pinecone pagination handler (pass search params)
2362 - $_POST['page'] = $page;
2363 - $_POST['search'] = $search_query;
2364 - $_POST['content_type'] = $content_type_filter;
2365 - $this->ajax_mxchat_refresh_pinecone_entries();
2366 - return;
2367 - }
2368 -
2369 - // WordPress DB pagination - MUST match initial page load logic exactly
2370 - global $wpdb;
2371 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2372 - $offset = ($page - 1) * $per_page;
2373 -
2374 - // Build WHERE clause for search and content type filtering
2375 - $where_clauses = array();
2376 - $where_values = array();
2377 -
2378 - if ($search_query) {
2379 - $where_clauses[] = "article_content LIKE %s";
2380 - $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
2381 - }
2382 -
2383 - if ($content_type_filter) {
2384 - switch ($content_type_filter) {
2385 - case 'manual':
2386 - $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
2387 - break;
2388 - case 'pdf':
2389 - $where_clauses[] = "source_url LIKE '%.pdf'";
2390 - break;
2391 - case 'url':
2392 - $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
2393 - break;
2394 - }
2395 - }
2396 -
2397 - $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
2398 -
2399 - // Count grouped entries with filters applied
2400 - if (!empty($where_values)) {
2401 - $count_args = array_merge($where_values, $where_values);
2402 - $count_query = $wpdb->prepare(
2403 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2404 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
2405 - ...$count_args
2406 - );
2407 - $total_records = $wpdb->get_var($count_query);
2408 - } else if (!empty($where_sql)) {
2409 - // Content type filter only (no search), no prepared values needed
2410 - $total_records = $wpdb->get_var(
2411 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2412 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
2413 - );
2414 - } else {
2415 - // No filters
2416 - $total_records = $wpdb->get_var(
2417 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2418 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2419 - );
2420 - }
2421 - $total_pages = ceil($total_records / $per_page);
2422 -
2423 - // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
2424 - if (!empty($where_values)) {
2425 - $query_args = array_merge($where_values, array($per_page, $offset));
2426 - $urls_query = $wpdb->prepare(
2427 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2428 - {$where_sql}
2429 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2430 - ...$query_args
2431 - );
2432 - } else if (!empty($where_sql)) {
2433 - $urls_query = $wpdb->prepare(
2434 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2435 - {$where_sql}
2436 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2437 - $per_page, $offset
2438 - );
2439 - } else {
2440 - $urls_query = $wpdb->prepare(
2441 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2442 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2443 - $per_page, $offset
2444 - );
2445 - }
2446 - $page_urls = $wpdb->get_results($urls_query);
2447 -
2448 - // Step 2: Build list of source_urls to fetch
2449 - $url_list = array();
2450 - $url_order_map = array();
2451 - $order_index = 0;
2452 - foreach ($page_urls as $url_row) {
2453 - $url = $url_row->source_url;
2454 - $url_list[] = $url;
2455 - $url_order_map[$url] = $order_index++;
2456 - }
2457 -
2458 - // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
2459 - $prompts = array();
2460 - if (!empty($url_list)) {
2461 - $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
2462 - if ($search_query) {
2463 - // Include search filter in the final fetch
2464 - $prompts_query = $wpdb->prepare(
2465 - "SELECT id, article_content, source_url, timestamp, role_restriction
2466 - FROM {$table_name}
2467 - WHERE source_url IN ($placeholders) AND article_content LIKE %s
2468 - ORDER BY timestamp DESC",
2469 - ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
2470 - );
2471 - } else {
2472 - $prompts_query = $wpdb->prepare(
2473 - "SELECT id, article_content, source_url, timestamp, role_restriction
2474 - FROM {$table_name}
2475 - WHERE source_url IN ($placeholders)
2476 - ORDER BY timestamp DESC",
2477 - $url_list
2478 - );
2479 - }
2480 - $prompts = $wpdb->get_results($prompts_query);
2481 - }
2482 -
2483 - // Group prompts by source_url for chunk display
2484 - $grouped_prompts = array();
2485 - foreach ($prompts as $prompt) {
2486 - $source_url = $prompt->source_url ?? '';
2487 -
2488 - // Parse chunk metadata using the proper chunker method (same as initial page load)
2489 - if (class_exists('MxChat_Chunker')) {
2490 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
2491 - $prompt->chunk_metadata = $chunk_meta['metadata'];
2492 - $prompt->display_content = $chunk_meta['text'];
2493 - } else {
2494 - $prompt->chunk_metadata = array();
2495 - $prompt->display_content = $prompt->article_content;
2496 - }
2497 -
2498 - if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
2499 - if (!isset($grouped_prompts[$source_url])) {
2500 - $grouped_prompts[$source_url] = array();
2501 - }
2502 - $grouped_prompts[$source_url][] = $prompt;
2503 - } else {
2504 - // Ungrouped entries
2505 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2506 - }
2507 - }
2508 -
2509 - // Sort groups by the original URL order (newest first)
2510 - uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
2511 - $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
2512 - $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
2513 - return $order_a - $order_b;
2514 - });
2515 -
2516 - // Sort each group internally by chunk_index
2517 - foreach ($grouped_prompts as $source_url => &$group) {
2518 - usort($group, function($a, $b) {
2519 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2520 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2521 - return $index_a - $index_b;
2522 - });
2523 - }
2524 - unset($group);
2525 -
2526 - // Build HTML for the table rows
2527 - ob_start();
2528 - $display_index = 0;
2529 - $current_page = $page;
2530 - $data_source = 'wordpress';
2531 - $current_bot_id = $bot_id;
2532 - $preview_length = 150;
2533 -
2534 - if (empty($grouped_prompts)) {
2535 - echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2536 - esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
2537 - echo '</td></tr>';
2538 - } else {
2539 - foreach ($grouped_prompts as $source_url => $group) {
2540 - $chunk_count = count($group);
2541 - $first_prompt = $group[0];
2542 - $display_index++;
2543 -
2544 - if ($chunk_count > 1) {
2545 - // Multiple chunks - show grouped row with expand button
2546 - $group_id = 'group-' . md5($source_url);
2547 - ?>
2548 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2549 - class="mxchat-chunk-group-header"
2550 - data-source="<?php echo esc_attr($data_source); ?>"
2551 - data-group-id="<?php echo esc_attr($group_id); ?>"
2552 - style="border-bottom: 1px solid var(--mxch-card-border);">
2553 - <td style="padding: 12px 16px; text-align: center;">
2554 - <input type="checkbox"
2555 - class="mxchat-entry-checkbox"
2556 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2557 - data-source="<?php echo esc_attr($data_source); ?>"
2558 - data-source-url="<?php echo esc_attr($source_url); ?>"
2559 - data-is-group="true"
2560 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2561 - </td>
2562 - <td style="padding: 12px 16px; font-size: 13px;">
2563 - <?php echo esc_html($first_prompt->id); ?>
2564 - </td>
2565 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2566 - <div class="mxchat-chunk-group-info">
2567 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2568 - <span class="dashicons dashicons-arrow-right-alt2"></span>
2569 - </button>
2570 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2571 - <span class="mxchat-chunk-preview">
2572 - <?php
2573 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
2574 - $content_preview = mb_substr($parent_content, 0, 100);
2575 - echo esc_html($content_preview . '...');
2576 - ?>
2577 - </span>
2578 - </div>
2579 - </td>
2580 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2581 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2582 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2583 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2584 - <?php esc_html_e('View Source', 'mxchat'); ?>
2585 - </a>
2586 - <?php else : ?>
2587 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2588 - <?php endif; ?>
2589 - </td>
2590 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2591 - <?php if ($data_source !== 'pinecone') : ?>
2592 - <button type="button"
2593 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2594 - data-source-url="<?php echo esc_attr($source_url); ?>"
2595 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2596 - data-data-source="<?php echo esc_attr($data_source); ?>"
2597 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2598 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2599 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2600 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2601 - </button>
2602 - <?php endif; ?>
2603 - <button type="button"
2604 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2605 - data-source-url="<?php echo esc_attr($source_url); ?>"
2606 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2607 - data-data-source="<?php echo esc_attr($data_source); ?>"
2608 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2609 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2610 - style="color: var(--mxch-error);"
2611 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2612 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2613 - </button>
2614 - </td>
2615 - </tr>
2616 - <?php
2617 - // Render hidden chunk rows
2618 - foreach ($group as $chunk_index => $chunk) {
2619 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2620 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2621 - $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
2622 - $content_preview = mb_strlen($content) > $preview_length
2623 - ? mb_substr($content, 0, $preview_length) . '...'
2624 - : $content;
2625 - ?>
2626 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2627 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2628 - data-source="<?php echo esc_attr($data_source); ?>"
2629 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2630 - <td style="padding: 12px 16px; text-align: center;">
2631 - <!-- Checkbox column placeholder for chunks (managed by group) -->
2632 - </td>
2633 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2634 - <!-- Hidden ID column for chunks -->
2635 - </td>
2636 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2637 - <div class="mxchat-accordion-wrapper">
2638 - <div class="mxchat-content-preview">
2639 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2640 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2641 - </span>
2642 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2643 - <?php if (mb_strlen($content) > $preview_length) : ?>
2644 - <button class="mxchat-expand-toggle" type="button">
2645 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2646 - </button>
2647 - <?php endif; ?>
2648 - </div>
2649 - <div class="mxchat-content-full" style="display: none;">
2650 - <div class="content-view">
2651 - <?php
2652 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2653 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2654 - echo wp_kses_post(wpautop($content));
2655 - echo '</div>';
2656 - } else {
2657 - echo wp_kses_post(wpautop($content));
2658 - }
2659 - ?>
2660 - </div>
2661 - </div>
2662 - </div>
2663 - </td>
2664 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2665 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2666 - </td>
2667 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2668 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2669 - </td>
2670 - </tr>
2671 - <?php
2672 - }
2673 - } else {
2674 - // Single entry - display normally with accordion
2675 - $prompt = $first_prompt;
2676 - $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
2677 - $content_preview = mb_strlen($content) > $preview_length
2678 - ? mb_substr($content, 0, $preview_length) . '...'
2679 - : $content;
2680 - ?>
2681 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2682 - data-source="<?php echo esc_attr($data_source); ?>"
2683 - style="border-bottom: 1px solid var(--mxch-card-border);">
2684 - <td style="padding: 12px 16px; text-align: center;">
2685 - <input type="checkbox"
2686 - class="mxchat-entry-checkbox"
2687 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2688 - data-source="<?php echo esc_attr($data_source); ?>"
2689 - data-source-url="<?php echo esc_attr($source_url); ?>"
2690 - data-is-group="false">
2691 - </td>
2692 - <td style="padding: 12px 16px; font-size: 13px;">
2693 - <?php echo esc_html($prompt->id); ?>
2694 - </td>
2695 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2696 - <div class="mxchat-accordion-wrapper">
2697 - <div class="mxchat-content-preview">
2698 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2699 - <?php if (mb_strlen($content) > $preview_length) : ?>
2700 - <button class="mxchat-expand-toggle" type="button">
2701 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2702 - </button>
2703 - <?php endif; ?>
2704 - </div>
2705 - <div class="mxchat-content-full" style="display: none;">
2706 - <div class="content-view">
2707 - <?php
2708 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2709 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2710 - echo wp_kses_post(wpautop($content));
2711 - echo '</div>';
2712 - } else {
2713 - echo wp_kses_post(wpautop($content));
2714 - }
2715 - ?>
2716 - </div>
2717 - </div>
2718 - </div>
2719 - </td>
2720 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2721 - <?php
2722 - $actual_source = $source_url;
2723 - if (strpos($source_url, '_ungrouped_') === 0) {
2724 - $actual_source = $prompt->source_url ?? '';
2725 - }
2726 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2727 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2728 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2729 - <?php esc_html_e('View', 'mxchat'); ?>
2730 - </a>
2731 - <?php else : ?>
2732 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2733 - <?php endif; ?>
2734 - </td>
2735 - <td style="padding: 12px 16px; white-space: nowrap;">
2736 - <button type="button"
2737 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2738 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
2739 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2740 - data-data-source="<?php echo esc_attr($data_source); ?>"
2741 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2742 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2743 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2744 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2745 - </button>
2746 - <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);">
2747 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2748 - </button>
2749 - </td>
2750 - </tr>
2751 - <?php
2752 - }
2753 - }
2754 - }
2755 - $html = ob_get_clean();
2756 -
2757 - // Generate pagination HTML (include search/filter data for subsequent pages)
2758 - $pagination_html = '';
2759 - if ($total_pages > 1) {
2760 - $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) . '">';
2761 -
2762 - // Previous button
2763 - if ($page > 1) {
2764 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2765 - }
2766 -
2767 - // Page numbers
2768 - $start_page = max(1, $page - 2);
2769 - $end_page = min($total_pages, $page + 2);
2770 -
2771 - if ($start_page > 1) {
2772 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2773 - if ($start_page > 2) {
2774 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2775 - }
2776 - }
2777 -
2778 - for ($i = $start_page; $i <= $end_page; $i++) {
2779 - if ($i == $page) {
2780 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2781 - } else {
2782 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2783 - }
2784 - }
2785 -
2786 - if ($end_page < $total_pages) {
2787 - if ($end_page < $total_pages - 1) {
2788 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2789 - }
2790 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2791 - }
2792 -
2793 - // Next button
2794 - if ($page < $total_pages) {
2795 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2796 - }
2797 -
2798 - $pagination_html .= '</div>';
2799 - }
2800 -
2801 - wp_send_json_success(array(
2802 - 'html' => $html,
2803 - 'pagination_html' => $pagination_html,
2804 - 'total_count' => $total_records,
2805 - 'total_pages' => $total_pages,
2806 - 'page' => $page,
2807 - 'per_page' => $per_page,
2808 - 'data_source' => 'wordpress'
2809 - ));
2810 -}
2811 -
2812 -/**
2813 - * AJAX handler to detect available sitemaps on the site
2814 - * Optimized for speed - only checks primary sitemap indexes first
2815 - */
2816 -public function ajax_mxchat_detect_sitemaps() {
2817 - check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
2818 -
2819 - if (!current_user_can('manage_options')) {
2820 - wp_send_json_error(array('message' => 'Unauthorized'));
2821 - return;
2822 - }
2823 -
2824 - $site_url = get_site_url();
2825 - $sitemaps = array();
2826 - $found_index = false;
2827 -
2828 - // Only check the main sitemap index files first (much faster)
2829 - // These are the primary entry points that contain sub-sitemaps
2830 - $primary_indexes = array(
2831 - 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
2832 - 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
2833 - 'sitemap.xml' => 'Standard', // Generic/AIOSEO
2834 - );
2835 -
2836 - foreach ($primary_indexes as $path => $source) {
2837 - $url = trailingslashit($site_url) . $path;
2838 -
2839 - $response = wp_remote_head($url, array(
2840 - 'timeout' => 10,
2841 - 'sslverify' => false,
2842 - 'redirection' => 1,
2843 - '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',
2844 - ));
2845 -
2846 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2847 - // Found a sitemap index - parse it to get sub-sitemaps
2848 - $sub_sitemaps = $this->parse_sitemap_index($url);
2849 - if (!empty($sub_sitemaps)) {
2850 - $sitemaps[] = array(
2851 - 'url' => $url,
2852 - 'type' => 'index',
2853 - 'source' => $source,
2854 - 'sub_sitemaps' => $sub_sitemaps
2855 - );
2856 - $found_index = true;
2857 - // Found a valid index, no need to check others
2858 - break;
2859 - }
2860 - }
2861 - }
2862 -
2863 - // If no sitemap index found, check for standalone sitemaps
2864 - if (!$found_index) {
2865 - $standalone_sitemaps = array(
2866 - 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2867 - 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2868 - );
2869 -
2870 - foreach ($standalone_sitemaps as $path => $info) {
2871 - $url = trailingslashit($site_url) . $path;
2872 -
2873 - $response = wp_remote_head($url, array(
2874 - 'timeout' => 2,
2875 - 'sslverify' => false
2876 - ));
2877 -
2878 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2879 - $sitemaps[] = array(
2880 - 'url' => $url,
2881 - 'type' => $info['type'],
2882 - 'source' => $info['source'],
2883 - 'url_count' => 0 // Skip URL count for speed
2884 - );
2885 - }
2886 - }
2887 - }
2888 -
2889 - wp_send_json_success(array(
2890 - 'sitemaps' => $sitemaps,
2891 - 'site_url' => $site_url
2892 - ));
2893 -}
2894 -
2895 -/**
2896 - * Parse a sitemap index to get sub-sitemaps
2897 - * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
2898 - */
2899 -private function parse_sitemap_index($url) {
2900 - $sub_sitemaps = array();
2901 -
2902 - $response = wp_remote_get($url, array(
2903 - 'timeout' => 30,
2904 - 'sslverify' => false,
2905 - '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',
2906 - 'headers' => array(
2907 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2908 - 'Accept-Language' => 'en-US,en;q=0.9',
2909 - ),
2910 - ));
2911 -
2912 - if (is_wp_error($response)) {
2913 - return $sub_sitemaps;
2914 - }
2915 -
2916 - $body = wp_remote_retrieve_body($response);
2917 - if (empty($body)) {
2918 - return $sub_sitemaps;
2919 - }
2920 -
2921 - // Suppress XML errors
2922 - libxml_use_internal_errors(true);
2923 - $xml = simplexml_load_string($body);
2924 - libxml_clear_errors();
2925 -
2926 - if ($xml === false) {
2927 - return $sub_sitemaps;
2928 - }
2929 -
2930 - // Check if it's a sitemap index (contains <sitemap> elements)
2931 - if (isset($xml->sitemap)) {
2932 - foreach ($xml->sitemap as $sitemap) {
2933 - $loc = (string) $sitemap->loc;
2934 - if (!empty($loc)) {
2935 - // Try to determine the type from the URL
2936 - $type = 'content';
2937 - if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
2938 - $type = 'taxonomy';
2939 - } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
2940 - $type = 'author';
2941 - }
2942 -
2943 - // Skip URL count - too slow to fetch for each sitemap
2944 - $sub_sitemaps[] = array(
2945 - 'url' => $loc,
2946 - 'type' => $type,
2947 - 'url_count' => 0, // Don't fetch - takes too long
2948 - 'name' => basename(parse_url($loc, PHP_URL_PATH))
2949 - );
2950 - }
2951 - }
2952 - }
2953 -
2954 - return $sub_sitemaps;
2955 -}
2956 -
2957 -/**
2958 - * Get URL count from a sitemap
2959 - */
2960 -private function get_sitemap_url_count($url) {
2961 - $response = wp_remote_get($url, array(
2962 - 'timeout' => 30,
2963 - 'sslverify' => false,
2964 - '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',
2965 - 'headers' => array(
2966 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2967 - 'Accept-Language' => 'en-US,en;q=0.9',
2968 - ),
2969 - ));
2970 -
2971 - if (is_wp_error($response)) {
2972 - return 0;
2973 - }
2974 -
2975 - $body = wp_remote_retrieve_body($response);
2976 - if (empty($body)) {
2977 - return 0;
2978 - }
2979 -
2980 - // Count <url> or <loc> elements
2981 - $count = preg_match_all('/<url>/i', $body, $matches);
2982 - return $count ?: 0;
2983 -}
2984 -
2985 -/**
2986 - * Get sitemaps declared in robots.txt
2987 - */
2988 -private function get_sitemaps_from_robots($site_url) {
2989 - $sitemaps = array();
2990 - $robots_url = trailingslashit($site_url) . 'robots.txt';
2991 -
2992 - $response = wp_remote_get($robots_url, array(
2993 - 'timeout' => 15,
2994 - 'sslverify' => false,
2995 - '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',
2996 - ));
2997 -
2998 - if (is_wp_error($response)) {
2999 - return $sitemaps;
3000 - }
3001 -
3002 - $body = wp_remote_retrieve_body($response);
3003 - if (empty($body)) {
3004 - return $sitemaps;
3005 - }
3006 -
3007 - // Find Sitemap: declarations
3008 - if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
3009 - foreach ($matches[1] as $sitemap_url) {
3010 - $sitemap_url = trim($sitemap_url);
3011 - if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
3012 - $sitemaps[] = $sitemap_url;
3013 - }
3014 - }
3015 - }
3016 -
3017 - return $sitemaps;
3018 -}
3019 -
3020 -public function mxchat_stop_processing() {
3021 - // Verify permissions
3022 - if (!current_user_can('manage_options')) {
3023 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
3024 - }
3025 -
3026 - // Verify nonce
3027 - check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
3028 -
3029 - global $wpdb;
3030 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
3031 -
3032 - // Get active queue IDs
3033 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
3034 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
3035 -
3036 - // Delete all pending items from active queues
3037 - if ($sitemap_queue_id) {
3038 - $wpdb->delete(
3039 - $table_name,
3040 - array(
3041 - 'queue_id' => $sitemap_queue_id,
3042 - 'status' => 'pending'
3043 - ),
3044 - array('%s', '%s')
3045 - );
3046 -
3047 - delete_transient('mxchat_active_queue_sitemap');
3048 - delete_transient('mxchat_last_sitemap_url');
3049 - }
3050 -
3051 - if ($pdf_queue_id) {
3052 - // Get PDF path before deleting
3053 - $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
3054 -
3055 - $wpdb->delete(
3056 - $table_name,
3057 - array(
3058 - 'queue_id' => $pdf_queue_id,
3059 - 'status' => 'pending'
3060 - ),
3061 - array('%s', '%s')
3062 - );
3063 -
3064 - // Delete PDF file
3065 - if ($pdf_path && file_exists($pdf_path)) {
3066 - wp_delete_file($pdf_path);
3067 - }
3068 -
3069 - delete_transient('mxchat_active_queue_pdf');
3070 - delete_transient('mxchat_last_pdf_url');
3071 - }
3072 -
3073 - // Redirect back with a success message
3074 - set_transient('mxchat_admin_notice_success',
3075 - esc_html__('Processing has been stopped successfully.', 'mxchat'),
3076 - 30
3077 - );
3078 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
3079 - exit;
3080 -}
3081 -
3082 -/**
3083 - * Get content list for processing
3084 - */
3085 -public function ajax_mxchat_get_content_list() {
3086 - // Verify the nonce
3087 - check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
3088 -
3089 - if (!current_user_can('manage_options')) {
3090 - wp_send_json_error(__('Unauthorized access', 'mxchat'));
3091 - }
3092 -
3093 - $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
3094 - $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
3095 - $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
3096 - $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
3097 - $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
3098 - $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
3099 -
3100 - // Build query args
3101 - $args = array(
3102 - 'posts_per_page' => $per_page,
3103 - 'paged' => $page,
3104 - 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
3105 - 'orderby' => 'date',
3106 - 'order' => 'DESC',
3107 - );
3108 -
3109 - // Handle post types - IMPROVED VERSION
3110 - if ($post_type !== 'all') {
3111 - $args['post_type'] = $post_type;
3112 - } else {
3113 - // Get all available post types that might contain content
3114 - $all_post_types = array();
3115 -
3116 - // First get all public post types
3117 - $public_types = get_post_types(array('public' => true), 'names');
3118 - $all_post_types = array_merge($all_post_types, $public_types);
3119 -
3120 - // Add common forum/community post types
3121 - $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
3122 - foreach ($forum_types as $forum_type) {
3123 - if (post_type_exists($forum_type)) {
3124 - $all_post_types[] = $forum_type;
3125 - }
3126 - }
3127 -
3128 - // Add other commonly used post types
3129 - $common_types = array('product', 'job_listing', 'event', 'portfolio');
3130 - foreach ($common_types as $common_type) {
3131 - if (post_type_exists($common_type)) {
3132 - $all_post_types[] = $common_type;
3133 - }
3134 - }
3135 -
3136 - // Remove duplicates and ensure we have at least some post types
3137 - $all_post_types = array_unique($all_post_types);
3138 -
3139 - if (empty($all_post_types)) {
3140 - // Fallback to basic post types
3141 - $all_post_types = array('post', 'page');
3142 - }
3143 -
3144 - $args['post_type'] = $all_post_types;
3145 -
3146 - // Debug logging to see what post types are being queried
3147 - //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
3148 - }
3149 -
3150 - if (!empty($search)) {
3151 - $args['s'] = $search;
3152 - }
3153 -
3154 - // Get processed data from storage
3155 - $processed_data = array();
3156 -
3157 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3158 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3159 -
3160 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3161 - // Get fresh data from Pinecone - no caching
3162 - $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
3163 - } else {
3164 - // WordPress DB checking with better URL matching for all post types
3165 - global $wpdb;
3166 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3167 - $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
3168 -
3169 - // Group items by source_url to count chunks
3170 - $url_chunk_counts = array();
3171 - $url_latest_timestamp = array();
3172 - $url_first_id = array();
3173 -
3174 - if (!empty($processed_items)) {
3175 - foreach ($processed_items as $item) {
3176 - $url = $item->source_url;
3177 - if (empty($url)) continue;
3178 -
3179 - // Count chunks per URL
3180 - if (!isset($url_chunk_counts[$url])) {
3181 - $url_chunk_counts[$url] = 0;
3182 - $url_latest_timestamp[$url] = $item->timestamp;
3183 - $url_first_id[$url] = $item->id;
3184 - }
3185 - $url_chunk_counts[$url]++;
3186 -
3187 - // Track latest timestamp
3188 - if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
3189 - $url_latest_timestamp[$url] = $item->timestamp;
3190 - }
3191 - }
3192 -
3193 - // Now build processed_data with chunk counts
3194 - foreach ($url_chunk_counts as $url => $chunk_count) {
3195 - $post_id = $this->mxchat_url_to_post_id_improved($url);
3196 -
3197 - if ($post_id) {
3198 - $processed_data[$post_id] = array(
3199 - 'db_id' => $url_first_id[$url],
3200 - 'timestamp' => $url_latest_timestamp[$url],
3201 - 'url' => $url,
3202 - 'source' => 'wordpress',
3203 - 'chunk_count' => $chunk_count
3204 - );
3205 - }
3206 - }
3207 - }
3208 - }
3209 -
3210 - // Get processed IDs as a simple array for in_array checks
3211 - $processed_ids = array_keys($processed_data);
3212 -
3213 - // Handle processed/unprocessed filter
3214 - if ($processed_filter === 'processed' && !empty($processed_ids)) {
3215 - $args['post__in'] = $processed_ids;
3216 - } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
3217 - $args['post__not_in'] = $processed_ids;
3218 - }
3219 -
3220 - // Run the query
3221 - $query = new WP_Query($args);
3222 - $content_items = array();
3223 -
3224 - if ($query->have_posts()) {
3225 - while ($query->have_posts()) {
3226 - $query->the_post();
3227 - $id = get_the_ID();
3228 - $post_date = get_the_date();
3229 - $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
3230 - $word_count = str_word_count(strip_tags(get_the_content()));
3231 -
3232 - $is_processed = in_array($id, $processed_ids);
3233 - $processed_date = '';
3234 - $db_record_id = 0;
3235 - $data_source = 'none';
3236 -
3237 - if ($is_processed && isset($processed_data[$id])) {
3238 - $item_data = $processed_data[$id];
3239 - $data_source = $item_data['source'];
3240 -
3241 - if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
3242 - // WordPress DB format
3243 - $timestamp = strtotime($item_data['timestamp']);
3244 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3245 - $db_record_id = $item_data['db_id'];
3246 - } elseif ($data_source === 'pinecone') {
3247 - // Pinecone format
3248 - $processed_date = $item_data['processed_date'];
3249 - $db_record_id = $item_data['db_id'];
3250 - }
3251 - }
3252 -
3253 - // Get chunk count for this item
3254 - $chunk_count = 0;
3255 - if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
3256 - $chunk_count = intval($processed_data[$id]['chunk_count']);
3257 - }
3258 -
3259 - $content_items[] = array(
3260 - 'id' => $id,
3261 - 'title' => get_the_title(),
3262 - 'permalink' => get_permalink(),
3263 - 'date' => $post_date,
3264 - 'type' => get_post_type(),
3265 - 'status' => get_post_status(),
3266 - 'excerpt' => $excerpt,
3267 - 'word_count' => $word_count,
3268 - 'already_processed' => $is_processed,
3269 - 'processed_date' => $processed_date,
3270 - 'db_record_id' => $db_record_id,
3271 - 'data_source' => $data_source,
3272 - 'chunk_count' => $chunk_count
3273 - );
3274 - }
3275 - wp_reset_postdata();
3276 - }
3277 -
3278 - $response = array(
3279 - 'items' => $content_items,
3280 - 'total' => $query->found_posts,
3281 - 'total_pages' => $query->max_num_pages,
3282 - 'current_page' => $page,
3283 - 'processed_count' => count($processed_ids)
3284 - );
3285 -
3286 - wp_send_json_success($response);
3287 - exit;
3288 -}
3289 -
3290 -
3291 -/**
3292 - * This function handles various WooCommerce URL formats and permalink structures
3293 - */
3294 -private function mxchat_url_to_post_id_improved($url) {
3295 - // First try the standard WordPress function
3296 - $post_id = url_to_postid($url);
3297 -
3298 - if ($post_id > 0) {
3299 - return $post_id;
3300 - }
3301 -
3302 - // If that fails, try more aggressive URL matching
3303 - // Remove trailing slashes and query parameters for better matching
3304 - $clean_url = rtrim($url, '/');
3305 - $clean_url = strtok($clean_url, '?'); // Remove query parameters
3306 -
3307 - // Try again with cleaned URL
3308 - $post_id = url_to_postid($clean_url);
3309 - if ($post_id > 0) {
3310 - return $post_id;
3311 - }
3312 -
3313 - // For bbPress forum topics, try extracting slug from URL
3314 - if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
3315 - // Handle bbPress URLs: /forums/topic/topic-name/
3316 - if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
3317 - $topic_slug = $matches[1];
3318 -
3319 - // Look up topic by slug
3320 - $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
3321 - if ($topic) {
3322 - return $topic->ID;
3323 - }
3324 -
3325 - // Alternative method: query by post_name
3326 - global $wpdb;
3327 - $post_id = $wpdb->get_var($wpdb->prepare(
3328 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3329 - $topic_slug
3330 - ));
3331 -
3332 - if ($post_id) {
3333 - return intval($post_id);
3334 - }
3335 - }
3336 -
3337 - // Handle simpler topic URLs: /topic/topic-name/
3338 - if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
3339 - $topic_slug = $matches[1];
3340 -
3341 - global $wpdb;
3342 - $post_id = $wpdb->get_var($wpdb->prepare(
3343 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3344 - $topic_slug
3345 - ));
3346 -
3347 - if ($post_id) {
3348 - return intval($post_id);
3349 - }
3350 - }
3351 - }
3352 -
3353 - // For WooCommerce products
3354 - if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
3355 - // Extract product slug from various URL formats
3356 - $product_slug = '';
3357 -
3358 - // Handle pretty permalinks: /product/product-name/
3359 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
3360 - $product_slug = $matches[1];
3361 - }
3362 - // Handle query parameters: ?product=product-name
3363 - elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
3364 - $product_slug = $matches[1];
3365 - }
3366 -
3367 - if (!empty($product_slug)) {
3368 - // Look up product by slug
3369 - $product = get_page_by_path($product_slug, OBJECT, 'product');
3370 - if ($product) {
3371 - return $product->ID;
3372 - }
3373 -
3374 - // Alternative method: query by post_name
3375 - global $wpdb;
3376 - $post_id = $wpdb->get_var($wpdb->prepare(
3377 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
3378 - $product_slug
3379 - ));
3380 -
3381 - if ($post_id) {
3382 - return intval($post_id);
3383 - }
3384 - }
3385 - }
3386 -
3387 - // Generic approach: try to extract slug and match against all post types
3388 - $parsed_url = wp_parse_url($clean_url);
3389 - $path = $parsed_url['path'] ?? '';
3390 -
3391 - if (!empty($path)) {
3392 - // Get the last part of the path as potential slug
3393 - $path_parts = array_filter(explode('/', trim($path, '/')));
3394 - $potential_slug = end($path_parts);
3395 -
3396 - if (!empty($potential_slug)) {
3397 - global $wpdb;
3398 -
3399 - // Try to find any post with this slug
3400 - $post_id = $wpdb->get_var($wpdb->prepare(
3401 - "SELECT ID FROM {$wpdb->posts}
3402 - WHERE post_name = %s
3403 - AND post_status IN ('publish', 'closed', 'private')
3404 - AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
3405 - ORDER BY CASE
3406 - WHEN post_type = 'post' THEN 1
3407 - WHEN post_type = 'page' THEN 2
3408 - WHEN post_type = 'topic' THEN 3
3409 - WHEN post_type = 'product' THEN 4
3410 - ELSE 5
3411 - END
3412 - LIMIT 1",
3413 - $potential_slug
3414 - ));
3415 -
3416 - if ($post_id) {
3417 - return intval($post_id);
3418 - }
3419 - }
3420 - }
3421 -
3422 - // ADDITIONAL: Try direct database lookup by URL variations
3423 - global $wpdb;
3424 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3425 -
3426 - // Try variations of the URL (with/without trailing slash, http/https)
3427 - $url_variations = array(
3428 - $url,
3429 - rtrim($url, '/'),
3430 - $url . '/',
3431 - str_replace('http://', 'https://', $url),
3432 - str_replace('https://', 'http://', $url),
3433 - str_replace('http://', 'https://', rtrim($url, '/')),
3434 - str_replace('https://', 'http://', rtrim($url, '/'))
3435 - );
3436 -
3437 - // Remove duplicates
3438 - $url_variations = array_unique($url_variations);
3439 -
3440 - foreach ($url_variations as $variation) {
3441 - $existing_record = $wpdb->get_row($wpdb->prepare(
3442 - "SELECT id, source_url FROM $table_name WHERE source_url = %s",
3443 - $variation
3444 - ));
3445 -
3446 - if ($existing_record) {
3447 - // Try to get post ID from this stored URL
3448 - $stored_post_id = url_to_postid($existing_record->source_url);
3449 - if ($stored_post_id > 0) {
3450 - return $stored_post_id;
3451 - }
3452 - }
3453 - }
3454 -
3455 - return 0; // No match found
3456 -}
3457 -/**
3458 - * Process selected content via AJAX
3459 - */
3460 -public function ajax_mxchat_process_selected_content() {
3461 - // Basic request validation
3462 - if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
3463 - wp_send_json_error('Invalid nonce');
3464 - exit;
3465 - }
3466 -
3467 - if (!current_user_can('manage_options')) {
3468 - wp_send_json_error('Unauthorized access');
3469 - exit;
3470 - }
3471 -
3472 - // Get post IDs - safely parse the array
3473 - $post_ids = array();
3474 - if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
3475 - foreach ($_POST['post_ids'] as $id) {
3476 - $post_ids[] = absint($id);
3477 - }
3478 - }
3479 -
3480 - if (empty($post_ids)) {
3481 - wp_send_json_error('No content selected');
3482 - exit;
3483 - }
3484 -
3485 - // Get bot_id from request
3486 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3487 -
3488 - // Process only ONE post at a time to avoid request size issues
3489 - $post_id = reset($post_ids);
3490 - $post = get_post($post_id);
3491 -
3492 - if (!$post) {
3493 - wp_send_json_error('Post not found');
3494 - exit;
3495 - }
3496 -
3497 - // Allow developers to modify post data before processing into knowledge base
3498 - $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
3499 -
3500 - // Get content including title, short description (for WooCommerce), and main content
3501 - $content = $post->post_title . "\n\n";
3502 -
3503 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
3504 - if (!empty($post->post_excerpt)) {
3505 - // Remove shortcode tags but preserve content inside them
3506 - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
3507 - $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
3508 - }
3509 -
3510 - // Add main content - remove shortcode tags but preserve content inside them
3511 - $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
3512 - $content .= wp_strip_all_tags($clean_content);
3513 -
3514 - // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
3515 - if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
3516 - $product = wc_get_product($post_id);
3517 -
3518 - if ($product) {
3519 - // Get pricing information
3520 - $regular_price = $product->get_regular_price();
3521 - $sale_price = $product->get_sale_price();
3522 - $price = $product->get_price();
3523 - $sku = $product->get_sku();
3524 -
3525 - // Get currency symbol
3526 - $currency_symbol = get_woocommerce_currency_symbol();
3527 -
3528 - // Add pricing information
3529 - $content .= "\n";
3530 - if (!empty($regular_price)) {
3531 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
3532 - } elseif (!empty($price)) {
3533 - $content .= "Price: " . $currency_symbol . $price . "\n";
3534 - }
3535 -
3536 - if (!empty($sale_price) && $sale_price !== $regular_price) {
3537 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
3538 - }
3539 -
3540 - // Handle variable products - show price range
3541 - if ($product->is_type('variable')) {
3542 - $min_price = $product->get_variation_price('min');
3543 - $max_price = $product->get_variation_price('max');
3544 - if ($min_price !== $max_price) {
3545 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
3546 - }
3547 - }
3548 -
3549 - if (!empty($sku)) {
3550 - $content .= "SKU: " . $sku . "\n";
3551 - }
3552 -
3553 - // Get product categories
3554 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
3555 - if (!empty($categories) && !is_wp_error($categories)) {
3556 - $content .= "Categories: " . implode(', ', $categories) . "\n";
3557 - }
3558 - }
3559 -
3560 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
3561 - $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
3562 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
3563 - foreach ($custom_tabs as $tab) {
3564 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3565 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
3566 -
3567 - if (!empty($tab_title) && !empty($tab_content)) {
3568 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3569 - }
3570 - }
3571 - }
3572 -
3573 - // Also check for reusable/saved tabs applied to this product
3574 - $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
3575 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
3576 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
3577 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
3578 - foreach ($applied_saved_tabs as $saved_tab_id) {
3579 - if (isset($saved_tabs[$saved_tab_id])) {
3580 - $tab = $saved_tabs[$saved_tab_id];
3581 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3582 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
3583 -
3584 - if (!empty($tab_title) && !empty($tab_content)) {
3585 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3586 - }
3587 - }
3588 - }
3589 - }
3590 - }
3591 - }
3592 -
3593 - // ADD ACF FIELDS SUPPORT
3594 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3595 - if (!empty($acf_fields)) {
3596 - $acf_content_parts = array();
3597 -
3598 - foreach ($acf_fields as $field_name => $field_value) {
3599 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3600 -
3601 - if (!empty($formatted_value)) {
3602 - $field_label = ucwords(str_replace('_', ' ', $field_name));
3603 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
3604 - }
3605 - }
3606 -
3607 - if (!empty($acf_content_parts)) {
3608 - $content .= "\n\n" . implode("\n", $acf_content_parts);
3609 - }
3610 - }
3611 -
3612 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3613 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
3614 - if (!empty($custom_meta)) {
3615 - $meta_content_parts = array();
3616 -
3617 - foreach ($custom_meta as $meta_key => $meta_value) {
3618 - // Convert meta key to readable label
3619 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
3620 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
3621 - }
3622 -
3623 - if (!empty($meta_content_parts)) {
3624 - $content .= "\n\n" . implode("\n", $meta_content_parts);
3625 - }
3626 - }
3627 -
3628 - // Debug logging for WordPress Import content
3629 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
3630 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
3631 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
3632 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
3633 -
3634 - // Note: Removed 10,000 char limit - chunking now handles large content properly
3635 -
3636 - // Get bot-specific API key
3637 - $bot_options = $this->get_bot_options($bot_id);
3638 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3639 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3640 -
3641 - if (strpos($selected_model, 'voyage') === 0) {
3642 - $api_key = $options['voyage_api_key'] ?? '';
3643 - $provider_name = 'Voyage AI';
3644 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3645 - $api_key = $options['gemini_api_key'] ?? '';
3646 - $provider_name = 'Google Gemini';
3647 - } else {
3648 - $api_key = $options['api_key'] ?? '';
3649 - $provider_name = 'OpenAI';
3650 - }
3651 -
3652 - if (empty($api_key)) {
3653 - MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing');
3654 - wp_send_json_error($provider_name . ' API key not configured');
3655 - exit;
3656 - }
3657 -
3658 - $source_url = get_permalink($post_id);
3659 - $vector_id = md5($source_url); // Vector ID for Pinecone
3660 -
3661 - // Check for existing content in bot-specific storage
3662 - $is_update = false;
3663 -
3664 - // Get bot-specific Pinecone configuration
3665 - $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
3666 - $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
3667 -
3668 - if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
3669 - // Check Pinecone for this bot
3670 - $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
3671 - if (isset($pinecone_data[$post_id])) {
3672 - $is_update = true;
3673 - }
3674 - } else {
3675 - // Check WordPress DB (same as before since it's shared)
3676 - global $wpdb;
3677 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3678 - $existing_record = $wpdb->get_row($wpdb->prepare(
3679 - "SELECT id FROM $table_name WHERE source_url = %s",
3680 - $source_url
3681 - ));
3682 -
3683 - if ($existing_record) {
3684 - $is_update = true;
3685 - }
3686 - }
3687 -
3688 - // UPDATED 2.5.6: Determine content type based on post_type
3689 - $post_type = $post->post_type;
3690 - $content_type = 'content'; // Default fallback
3691 -
3692 - // Map WordPress post types to content types
3693 - switch ($post_type) {
3694 - case 'post':
3695 - $content_type = 'post';
3696 - break;
3697 - case 'page':
3698 - $content_type = 'page';
3699 - break;
3700 - case 'product':
3701 - $content_type = 'product';
3702 - break;
3703 - default:
3704 - // For custom post types, use the post type name
3705 - $content_type = sanitize_key($post_type);
3706 - break;
3707 - }
3708 -
3709 - // Use the centralized utility function with bot_id and content_type
3710 - $result = MxChat_Utils::submit_content_to_db(
3711 - $content,
3712 - $source_url,
3713 - $api_key,
3714 - $vector_id,
3715 - $bot_id,
3716 - $content_type
3717 - );
3718 -
3719 - if (is_wp_error($result)) {
3720 - MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
3721 - wp_send_json_error('Storage failed: ' . $result->get_error_message());
3722 - exit;
3723 - }
3724 -
3725 - // Automatically apply role restriction based on tags
3726 - $this->apply_role_restriction_to_post($post_id, $source_url);
3727 -
3728 - $operation_type = $is_update ? 'update' : 'new';
3729 -
3730 - // Count ACF fields for debugging
3731 - $acf_field_count = count($acf_fields);
3732 -
3733 - // Success response with minimal data
3734 - wp_send_json_success(array(
3735 - 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
3736 - 'post_id' => $post_id,
3737 - 'title' => $post->post_title,
3738 - 'operation_type' => $operation_type,
3739 - 'vector_id' => $vector_id,
3740 - 'acf_fields_found' => $acf_field_count,
3741 - 'content_preview' => substr($content, 0, 100) . '...',
3742 - 'bot_id' => $bot_id
3743 - ));
3744 - exit;
3745 -}
3746 -
3747 -private function apply_role_restriction_to_post($post_id, $source_url) {
3748 - // Get tag-role mappings
3749 - $mappings = get_option('mxchat_tag_role_mappings', array());
3750 -
3751 - if (empty($mappings)) {
3752 - return; // No mappings, leave as public
3753 - }
3754 -
3755 - // Get all tags for the post
3756 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
3757 -
3758 - if (empty($post_tags)) {
3759 - return; // No tags, leave as public
3760 - }
3761 -
3762 - // Determine the highest role restriction based on tags
3763 - $highest_role = 'public';
3764 - $role_hierarchy = array(
3765 - 'public' => 0,
3766 - 'logged_in' => 1,
3767 - 'subscriber' => 2,
3768 - 'contributor' => 3,
3769 - 'author' => 4,
3770 - 'editor' => 5,
3771 - 'administrator' => 6
3772 - );
3773 -
3774 - foreach ($post_tags as $tag_slug) {
3775 - if (isset($mappings[$tag_slug])) {
3776 - $role = $mappings[$tag_slug];
3777 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
3778 - $highest_role = $role;
3779 - }
3780 - }
3781 - }
3782 -
3783 - // If no restricted tags found, return (leave as public)
3784 - if ($highest_role === 'public') {
3785 - return;
3786 - }
3787 -
3788 - // Update the role restriction in the database
3789 - global $wpdb;
3790 -
3791 - // Check if using Pinecone
3792 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3793 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3794 -
3795 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3796 - // Update Pinecone role restriction
3797 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
3798 - $vector_id = md5($source_url);
3799 -
3800 - $wpdb->replace(
3801 - $roles_table,
3802 - array(
3803 - 'vector_id' => $vector_id,
3804 - 'role_restriction' => $highest_role,
3805 - 'updated_at' => current_time('mysql')
3806 - ),
3807 - array('%s', '%s', '%s')
3808 - );
3809 - } else {
3810 - // Update WordPress DB
3811 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3812 -
3813 - $wpdb->update(
3814 - $table_name,
3815 - array('role_restriction' => $highest_role),
3816 - array('source_url' => $source_url),
3817 - array('%s'),
3818 - array('%s')
3819 - );
3820 - }
3821 -}
3822 -
3823 -public function mxchat_get_public_post_types() {
3824 - // Get all public post types
3825 - $post_types = get_post_types(array('public' => true), 'objects');
3826 - $post_type_options = array();
3827 -
3828 - foreach ($post_types as $post_type) {
3829 - $post_type_options[$post_type->name] = $post_type->label;
3830 - }
3831 -
3832 - // Also include common forum/community post types that might not be marked as public
3833 - $additional_types = array(
3834 - 'topic' => 'Forum Topics (bbPress)',
3835 - 'reply' => 'Forum Replies (bbPress)',
3836 - 'forum' => 'Forums (bbPress)',
3837 - 'wpforo_topic' => 'wpForo Topics',
3838 - 'wpforo_post' => 'wpForo Posts'
3839 - );
3840 -
3841 - foreach ($additional_types as $type_name => $type_label) {
3842 - if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
3843 - $post_type_options[$type_name] = $type_label;
3844 - }
3845 - }
3846 -
3847 - return $post_type_options;
3848 -}
3849 -
3850 -/**
3851 - * Retrieves processed content from Pinecone API
3852 - */
3853 -public function mxchat_get_pinecone_processed_content($pinecone_options) {
3854 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3855 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3856 -
3857 - if (empty($api_key) || empty($host)) {
3858 - return array();
3859 - }
3860 -
3861 - $pinecone_data = array();
3862 -
3863 - try {
3864 - // Always get fresh data from Pinecone
3865 - $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
3866 -
3867 - // Method 2: Final fallback - try stats endpoint (if available)
3868 - if (empty($pinecone_data)) {
3869 - $stats_url = "https://{$host}/describe_index_stats";
3870 -
3871 - $response = wp_remote_post($stats_url, array(
3872 - 'headers' => array(
3873 - 'Api-Key' => $api_key,
3874 - 'Content-Type' => 'application/json'
3875 - ),
3876 - 'body' => json_encode(array()),
3877 - 'timeout' => 30
3878 - ));
3879 -
3880 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3881 - $body = wp_remote_retrieve_body($response);
3882 - $stats_data = json_decode($body, true);
3883 - }
3884 - }
3885 -
3886 - } catch (Exception $e) {
3887 - // Log error but return fresh data only
3888 - }
3889 -
3890 - return $pinecone_data;
3891 -}
3892 -public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
3893 - //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
3894 -
3895 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3896 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3897 -
3898 - if (empty($api_key) || empty($host) || empty($vector_ids)) {
3899 - //error_log('DEBUG: Missing parameters for fetch by IDs');
3900 - return array();
3901 - }
3902 -
3903 - try {
3904 - $fetch_url = "https://{$host}/vectors/fetch";
3905 - //error_log('DEBUG: Fetch URL: ' . $fetch_url);
3906 - //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
3907 -
3908 - // Pinecone fetch API allows fetching specific vectors by ID
3909 - $fetch_data = array(
3910 - 'ids' => array_values($vector_ids)
3911 - );
3912 -
3913 - $response = wp_remote_post($fetch_url, array(
3914 - 'headers' => array(
3915 - 'Api-Key' => $api_key,
3916 - 'Content-Type' => 'application/json'
3917 - ),
3918 - 'body' => json_encode($fetch_data),
3919 - 'timeout' => 30
3920 - ));
3921 -
3922 - if (is_wp_error($response)) {
3923 - //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
3924 - return array();
3925 - }
3926 -
3927 - $response_code = wp_remote_retrieve_response_code($response);
3928 - //error_log('DEBUG: Fetch response code: ' . $response_code);
3929 -
3930 - if ($response_code !== 200) {
3931 - $error_body = wp_remote_retrieve_body($response);
3932 - //error_log('DEBUG: Fetch failed with body: ' . $error_body);
3933 - return array();
3934 - }
3935 -
3936 - $body = wp_remote_retrieve_body($response);
3937 - $data = json_decode($body, true);
3938 -
3939 - //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
3940 -
3941 - if (!isset($data['vectors'])) {
3942 - //error_log('DEBUG: No vectors key in response');
3943 - return array();
3944 - }
3945 -
3946 - $processed_data = array();
3947 -
3948 - foreach ($data['vectors'] as $vector_id => $vector_data) {
3949 - $metadata = $vector_data['metadata'] ?? array();
3950 - $source_url = $metadata['source_url'] ?? '';
3951 -
3952 - if (!empty($source_url)) {
3953 - $post_id = url_to_postid($source_url);
3954 - if ($post_id) {
3955 - $created_at = $metadata['created_at'] ?? '';
3956 - $processed_date = 'Recently';
3957 -
3958 - if (!empty($created_at)) {
3959 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
3960 - if ($timestamp) {
3961 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3962 - }
3963 - }
3964 -
3965 - $processed_data[$post_id] = array(
3966 - 'db_id' => $vector_id,
3967 - 'processed_date' => $processed_date,
3968 - 'url' => $source_url,
3969 - 'source' => 'pinecone',
3970 - 'timestamp' => $timestamp ?? current_time('timestamp')
3971 - );
3972 - }
3973 - }
3974 - }
3975 -
3976 - //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
3977 - return $processed_data;
3978 -
3979 - } catch (Exception $e) {
3980 - //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
3981 - return array();
3982 - }
3983 -}
3984 -
3985 -/**
3986 - * Get embedding dimensions based on the selected model.
3987 - */
3988 -private function mxchat_get_embedding_dimensions() {
3989 - $options = get_option('mxchat_options', array());
3990 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3991 -
3992 - $model_dimensions = array(
3993 - 'text-embedding-ada-002' => 1536,
3994 - 'text-embedding-3-small' => 1536,
3995 - 'text-embedding-3-large' => 3072,
3996 - 'voyage-2' => 1024,
3997 - 'voyage-large-2' => 1536,
3998 - 'voyage-3-large' => 2048,
3999 - 'gemini-embedding-001' => 1536,
4000 - );
4001 -
4002 - if (strpos($selected_model, 'voyage-3-large') === 0) {
4003 - $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
4004 - return intval($custom_dimensions);
4005 - }
4006 -
4007 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4008 - $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
4009 - return intval($custom_dimensions);
4010 - }
4011 -
4012 - return $model_dimensions[$selected_model] ?? 1536;
4013 -}
4014 -
4015 -/**
4016 - * Scan Pinecone for processed content
4017 - */
4018 -public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4019 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4020 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4021 -
4022 - if (empty($api_key) || empty($host)) {
4023 - return array();
4024 - }
4025 -
4026 - try {
4027 - // Use multiple random vectors to get better coverage
4028 - $all_matches = array();
4029 - $seen_ids = array();
4030 -
4031 - // Get correct dimensions for the configured embedding model
4032 - $dimensions = $this->mxchat_get_embedding_dimensions();
4033 -
4034 - // Try 3 different random vectors to get better coverage
4035 - for ($i = 0; $i < 3; $i++) {
4036 - $query_url = "https://{$host}/query";
4037 -
4038 - // Generate a random unit vector instead of zeros
4039 - $random_vector = array();
4040 - for ($j = 0; $j < $dimensions; $j++) {
4041 - $random_vector[] = (rand(-1000, 1000) / 1000.0);
4042 - }
4043 -
4044 - // Normalize the vector to unit length
4045 - $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
4046 - if ($magnitude > 0) {
4047 - $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
4048 - }
4049 -
4050 - $query_data = array(
4051 - 'includeMetadata' => true,
4052 - 'includeValues' => false,
4053 - 'topK' => 10000,
4054 - 'vector' => $random_vector
4055 - );
4056 -
4057 - $response = wp_remote_post($query_url, array(
4058 - 'headers' => array(
4059 - 'Api-Key' => $api_key,
4060 - 'Content-Type' => 'application/json'
4061 - ),
4062 - 'body' => json_encode($query_data),
4063 - 'timeout' => 30
4064 - ));
4065 -
4066 - if (is_wp_error($response)) {
4067 - continue;
4068 - }
4069 -
4070 - $response_code = wp_remote_retrieve_response_code($response);
4071 -
4072 - if ($response_code !== 200) {
4073 - continue;
4074 - }
4075 -
4076 - $body = wp_remote_retrieve_body($response);
4077 - $data = json_decode($body, true);
4078 -
4079 - if (isset($data['matches'])) {
4080 - foreach ($data['matches'] as $match) {
4081 - $match_id = $match['id'] ?? '';
4082 - if (!empty($match_id) && !isset($seen_ids[$match_id])) {
4083 - $all_matches[] = $match;
4084 - $seen_ids[$match_id] = true;
4085 - }
4086 - }
4087 - }
4088 - }
4089 -
4090 - // Convert matches to processed data format, grouping by URL to count chunks
4091 - $processed_data = array();
4092 - $url_chunk_counts = array();
4093 -
4094 - foreach ($all_matches as $match) {
4095 - $metadata = $match['metadata'] ?? array();
4096 - $source_url = $metadata['source_url'] ?? '';
4097 - $match_id = $match['id'] ?? '';
4098 -
4099 - if (!empty($source_url) && !empty($match_id)) {
4100 - $post_id = url_to_postid($source_url);
4101 - if ($post_id) {
4102 - // Count chunks per post_id
4103 - if (!isset($url_chunk_counts[$post_id])) {
4104 - $url_chunk_counts[$post_id] = 0;
4105 - }
4106 - $url_chunk_counts[$post_id]++;
4107 -
4108 - $created_at = $metadata['created_at'] ?? '';
4109 - $processed_date = 'Recently';
4110 -
4111 - if (!empty($created_at)) {
4112 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4113 - if ($timestamp) {
4114 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4115 - }
4116 - }
4117 -
4118 - // Only store if not already set, or update with newer timestamp
4119 - if (!isset($processed_data[$post_id]) ||
4120 - ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
4121 - $processed_data[$post_id] = array(
4122 - 'db_id' => $match_id,
4123 - 'processed_date' => $processed_date,
4124 - 'url' => $source_url,
4125 - 'source' => 'pinecone',
4126 - 'timestamp' => $timestamp ?? current_time('timestamp')
4127 - );
4128 - }
4129 - }
4130 - }
4131 - }
4132 -
4133 - // Add chunk counts to processed data
4134 - foreach ($url_chunk_counts as $post_id => $chunk_count) {
4135 - if (isset($processed_data[$post_id])) {
4136 - $processed_data[$post_id]['chunk_count'] = $chunk_count;
4137 - }
4138 - }
4139 -
4140 - return $processed_data;
4141 -
4142 - } catch (Exception $e) {
4143 - return array();
4144 - }
4145 -}
4146 -/**
4147 - * Generate embeddings from input text for MXChat with bot support
4148 - */
4149 -private function mxchat_generate_embedding($text, $bot_id = 'default') {
4150 - // Enable detailed logging for debugging
4151 - //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
4152 - //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
4153 -
4154 - // Get bot-specific options
4155 - $bot_options = $this->get_bot_options($bot_id);
4156 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4157 -
4158 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4159 - //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4160 -
4161 - // Determine provider and endpoint
4162 - if (strpos($selected_model, 'voyage') === 0) {
4163 - $api_key = $options['voyage_api_key'] ?? '';
4164 - $endpoint = 'https://api.voyageai.com/v1/embeddings';
4165 - $provider_name = 'Voyage AI';
4166 - //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
4167 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4168 - $api_key = $options['gemini_api_key'] ?? '';
4169 - $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4170 - $provider_name = 'Google Gemini';
4171 - //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
4172 - } else {
4173 - $api_key = $options['api_key'] ?? '';
4174 - $endpoint = 'https://api.openai.com/v1/embeddings';
4175 - $provider_name = 'OpenAI';
4176 - //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
4177 - }
4178 -
4179 - //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
4180 -
4181 - if (empty($api_key)) {
4182 - $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
4183 - //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
4184 - return $error_message;
4185 - }
4186 -
4187 - // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
4188 - $estimated_tokens = ceil(str_word_count($text) / 0.75);
4189 - //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
4190 -
4191 - if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
4192 - //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
4193 - // Consider truncating text here
4194 - }
4195 -
4196 - // Prepare request body based on provider
4197 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4198 - // Gemini API format
4199 - $request_body = array(
4200 - 'model' => 'models/' . $selected_model,
4201 - 'content' => array(
4202 - 'parts' => array(
4203 - array('text' => $text)
4204 - )
4205 - )
4206 - );
4207 -
4208 - // Set output dimensionality to 1536 for consistency with other models
4209 - $request_body['outputDimensionality'] = 1536;
4210 - } else {
4211 - // OpenAI/Voyage API format
4212 - $request_body = array(
4213 - 'model' => $selected_model,
4214 - 'input' => $text
4215 - );
4216 -
4217 - // Add output_dimension for voyage-3-large model
4218 - if ($selected_model === 'voyage-3-large') {
4219 - $request_body['output_dimension'] = 2048;
4220 - }
4221 - }
4222 -
4223 - //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
4224 -
4225 - // Prepare headers based on provider
4226 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4227 - // Gemini uses API key as query parameter
4228 - $endpoint .= '?key=' . $api_key;
4229 - $headers = array(
4230 - 'Content-Type' => 'application/json'
4231 - );
4232 - } else {
4233 - // OpenAI/Voyage use Bearer token
4234 - $headers = array(
4235 - 'Authorization' => 'Bearer ' . $api_key,
4236 - 'Content-Type' => 'application/json'
4237 - );
4238 - }
4239 -
4240 - // Make API request
4241 - //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
4242 - $response = wp_remote_post($endpoint, array(
4243 - 'body' => wp_json_encode($request_body),
4244 - 'headers' => $headers,
4245 - 'timeout' => 60 // Increased timeout for large inputs
4246 - ));
4247 -
4248 - // Handle wp_remote_post errors
4249 - if (is_wp_error($response)) {
4250 - $error_message = $response->get_error_message();
4251 - //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
4252 - return 'Connection error: ' . $error_message;
4253 - }
4254 -
4255 - // Get and check HTTP response code
4256 - $http_code = wp_remote_retrieve_response_code($response);
4257 - //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
4258 -
4259 - if ($http_code !== 200) {
4260 - $error_body = wp_remote_retrieve_body($response);
4261 - //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
4262 -
4263 - // Try to parse error for more details
4264 - $error_json = json_decode($error_body, true);
4265 - if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
4266 - $error_type = $error_json['error']['type'] ?? 'unknown';
4267 - $error_message = $error_json['error']['message'] ?? 'No message';
4268 - //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
4269 - //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
4270 -
4271 - // Customize error message for common API errors
4272 - if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
4273 - $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
4274 - } elseif ($error_type === 'authentication_error') {
4275 - $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
4276 - }
4277 -
4278 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4279 - return $error_message;
4280 - }
4281 -
4282 - $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
4283 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4284 - return $error_message;
4285 - }
4286 -
4287 - // Parse response body
4288 - $response_body = wp_remote_retrieve_body($response);
4289 - //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
4290 -
4291 - $response_data = json_decode($response_body, true);
4292 -
4293 - if (json_last_error() !== JSON_ERROR_NONE) {
4294 - $error = json_last_error_msg();
4295 - //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
4296 - //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
4297 - return "Failed to parse API response: $error";
4298 - }
4299 -
4300 - // Handle different response formats based on provider
4301 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4302 - // Gemini API response format
4303 - if (isset($response_data['embedding']['values'])) {
4304 - $embedding_dimensions = count($response_data['embedding']['values']);
4305 - //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
4306 -
4307 - // Check if embedding dimensions are as expected (should be 1536)
4308 - if ($embedding_dimensions !== 1536) {
4309 - //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
4310 - }
4311 -
4312 - return $response_data['embedding']['values'];
4313 - } else {
4314 - //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
4315 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4316 -
4317 - if (isset($response_data['error'])) {
4318 - $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
4319 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4320 - return $error_message;
4321 - }
4322 -
4323 - $error_message = "Invalid Gemini API response format: No embedding found";
4324 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4325 - return $error_message;
4326 - }
4327 - } else {
4328 - // OpenAI/Voyage API response format
4329 - if (isset($response_data['data'][0]['embedding'])) {
4330 - $embedding_dimensions = count($response_data['data'][0]['embedding']);
4331 - //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
4332 -
4333 - // Check if embedding dimensions are as expected
4334 - if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
4335 - ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
4336 - //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
4337 - }
4338 -
4339 - return $response_data['data'][0]['embedding'];
4340 - } else {
4341 - //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
4342 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4343 -
4344 - if (isset($response_data['error'])) {
4345 - $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
4346 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4347 - return $error_message;
4348 - }
4349 -
4350 - $error_message = "Invalid API response format: No embedding found";
4351 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4352 - return $error_message;
4353 - }
4354 - }
4355 -}
4356 -
4357 -/**
4358 - * Get bot-specific options for multi-bot functionality
4359 - * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
4360 - */
4361 -private function get_bot_options($bot_id = 'default') {
4362 - //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
4363 -
4364 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4365 - //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
4366 - return array();
4367 - }
4368 -
4369 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
4370 -
4371 - if (!empty($bot_options)) {
4372 - //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
4373 - if (isset($bot_options['similarity_threshold'])) {
4374 - //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
4375 - }
4376 - }
4377 -
4378 - return is_array($bot_options) ? $bot_options : array();
4379 -}
4380 -
4381 -/**
4382 - * Get bot-specific Pinecone configuration
4383 - * Used in the knowledge retrieval functions
4384 - */
4385 -// Also add debugging to your get_bot_pinecone_config function
4386 -private function get_bot_pinecone_config($bot_id = 'default') {
4387 - //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4388 -
4389 - // If default bot or multi-bot add-on not active, use default Pinecone config
4390 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4391 - //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4392 - $addon_options = get_option('mxchat_pinecone_addon_options', array());
4393 - $config = array(
4394 - 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4395 - 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4396 - 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4397 - 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4398 - );
4399 - //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4400 - return $config;
4401 - }
4402 -
4403 - //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4404 -
4405 - // Hook for multi-bot add-on to provide bot-specific Pinecone config
4406 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4407 -
4408 - if (!empty($bot_pinecone_config)) {
4409 - //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4410 - //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4411 - //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4412 - //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4413 - } else {
4414 - //error_log("MXCHAT DEBUG: Filter returned empty config!");
4415 - }
4416 -
4417 - return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4418 -}
4419 -
4420 -
4421 -public function mxchat_ajax_dismiss_completed_status() {
4422 - try {
4423 - // Verify the request
4424 - check_ajax_referer('mxchat_status_nonce', 'nonce');
4425 -
4426 - if (!current_user_can('manage_options')) {
4427 - wp_send_json_error('Unauthorized access');
4428 - exit;
4429 - }
4430 -
4431 - $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
4432 -
4433 - if ($card_type === 'pdf') {
4434 - // Clear PDF status
4435 - $pdf_url = get_transient('mxchat_last_pdf_url');
4436 - if ($pdf_url) {
4437 - delete_transient('mxchat_pdf_status_' . md5($pdf_url));
4438 - delete_transient('mxchat_last_pdf_url');
4439 - }
4440 - } elseif ($card_type === 'sitemap') {
4441 - // Clear sitemap status
4442 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
4443 - if ($sitemap_url) {
4444 - delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
4445 - delete_transient('mxchat_last_sitemap_url');
4446 - }
4447 - }
4448 -
4449 - wp_send_json_success(array('message' => 'Status dismissed successfully'));
4450 -
4451 - } catch (Exception $e) {
4452 - wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
4453 - }
4454 -}
4455 -
4456 -/**
4457 - * Render completed status cards on page load
4458 - * This ensures completed processing status persists through page refreshes
4459 - */
4460 -public function mxchat_render_completed_status_cards() {
4461 - $output = '';
4462 -
4463 - // Check for completed PDF status
4464 - $pdf_url = get_transient('mxchat_last_pdf_url');
4465 - if ($pdf_url) {
4466 - $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
4467 - if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
4468 - $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
4469 - }
4470 - }
4471 -
4472 - // Check for completed sitemap status
4473 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
4474 - if ($sitemap_url) {
4475 - $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
4476 - if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
4477 - $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
4478 - }
4479 - }
4480 -
4481 - return $output;
4482 -}
4483 -
4484 -/**
4485 - * Render PDF status card HTML
4486 - */
4487 -private function mxchat_render_pdf_status_card($status, $pdf_url) {
4488 - $html = '<div class="mxchat-status-card" data-card-type="pdf">';
4489 - $html .= '<div class="mxchat-status-header">';
4490 - $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
4491 -
4492 - // Add dismiss button for completed status
4493 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
4494 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4495 - }
4496 -
4497 - // Process Batch button for processing status
4498 - if ($status['status'] === 'processing') {
4499 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
4500 - data-process-type="pdf"
4501 - data-url="' . esc_attr($pdf_url) . '">
4502 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4503 - }
4504 -
4505 - // Add status badges
4506 - if ($status['status'] === 'error') {
4507 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4508 - } elseif ($status['status'] === 'complete') {
4509 - if ($status['failed_pages'] > 0) {
4510 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4511 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
4512 - } else {
4513 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4514 - }
4515 - }
4516 -
4517 - $html .= '</div>'; // End header
4518 -
4519 - // Progress bar
4520 - $html .= '<div class="mxchat-progress-bar">';
4521 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4522 - $html .= '</div>';
4523 -
4524 - // Status details
4525 - $html .= '<div class="mxchat-status-details">';
4526 - $html .= '<p>' . sprintf(
4527 - esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
4528 - $status['processed_pages'],
4529 - $status['total_pages'],
4530 - $status['percentage']
4531 - ) . '</p>';
4532 -
4533 - // Show failed pages count if any
4534 - if ($status['failed_pages'] > 0) {
4535 - $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
4536 - }
4537 -
4538 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4539 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4540 -
4541 - // Add completion summary if available AND it's an array
4542 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4543 - $summary = $status['completion_summary'];
4544 - $html .= '<div class="mxchat-completion-summary">';
4545 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4546 - $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
4547 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
4548 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
4549 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4550 - $html .= '</div>';
4551 - }
4552 -
4553 - // Add failed pages list if any AND it's an array
4554 - if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
4555 - $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
4556 - }
4557 -
4558 - // Add error message if any
4559 - if (isset($status['error']) && !empty($status['error'])) {
4560 - $html .= '<div class="mxchat-error-notice">';
4561 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4562 - $html .= '</div>';
4563 - }
4564 -
4565 - $html .= '</div>'; // End details
4566 - $html .= '</div>'; // End card
4567 -
4568 - return $html;
4569 -}
4570 -/**
4571 - * Render sitemap status card HTML
4572 - */
4573 -private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
4574 - $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
4575 - $html .= '<div class="mxchat-status-header">';
4576 - $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
4577 -
4578 - // Add dismiss button for completed status
4579 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
4580 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4581 - }
4582 -
4583 - // Process Batch button for processing status
4584 - if ($status['status'] === 'processing') {
4585 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
4586 - data-process-type="sitemap"
4587 - data-url="' . esc_attr($sitemap_url) . '">
4588 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4589 - }
4590 -
4591 - // Add status badges
4592 - if ($status['status'] === 'error') {
4593 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4594 - } elseif ($status['status'] === 'complete') {
4595 - if ($status['failed_urls'] > 0) {
4596 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4597 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
4598 - } else {
4599 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4600 - }
4601 - }
4602 -
4603 - $html .= '</div>'; // End header
4604 -
4605 - // Progress bar
4606 - $html .= '<div class="mxchat-progress-bar">';
4607 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4608 - $html .= '</div>';
4609 -
4610 - // Status details
4611 - $html .= '<div class="mxchat-status-details">';
4612 - $html .= '<p>' . sprintf(
4613 - esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
4614 - $status['processed_urls'],
4615 - $status['total_urls'],
4616 - $status['percentage']
4617 - ) . '</p>';
4618 -
4619 - // Show failed URLs count if any
4620 - if ($status['failed_urls'] > 0) {
4621 - $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
4622 - }
4623 -
4624 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4625 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4626 -
4627 - // Add completion summary if available AND it's an array
4628 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4629 - $summary = $status['completion_summary'];
4630 - $html .= '<div class="mxchat-completion-summary">';
4631 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4632 - $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
4633 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
4634 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
4635 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4636 - $html .= '</div>';
4637 - }
4638 -
4639 - // Add error messages if any (but not the failed URLs list)
4640 - if (!empty($status['error']) || !empty($status['last_error'])) {
4641 - $html .= '<div class="mxchat-error-notice">';
4642 -
4643 - if (!empty($status['error'])) {
4644 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4645 - }
4646 -
4647 - if (!empty($status['last_error'])) {
4648 - $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
4649 - }
4650 -
4651 - $html .= '</div>';
4652 - }
4653 -
4654 - $html .= '</div>'; // End details
4655 - $html .= '</div>'; // End card
4656 -
4657 - return $html;
4658 -}
4659 -
4660 -
4661 -/**
4662 - * Render failed pages list
4663 - */
4664 -private function mxchat_render_failed_pages_list($failed_pages_list) {
4665 - // Validate that $failed_pages_list is an array and not empty
4666 - if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
4667 - return '';
4668 - }
4669 -
4670 - $html = '<div class="mxchat-error-notice">';
4671 - $html .= '<div class="mxchat-failed-pages-container">';
4672 - $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
4673 - $html .= '<details>';
4674 - $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
4675 - $html .= '<div class="mxchat-failed-pages-list">';
4676 -
4677 - // Create table for failed pages
4678 - $html .= '<table class="widefat striped">';
4679 - $html .= '<thead><tr>';
4680 - $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
4681 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4682 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4683 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4684 - $html .= '</tr></thead><tbody>';
4685 -
4686 - // Sort failed pages by most recent
4687 - $sorted_failed_pages = $failed_pages_list;
4688 - usort($sorted_failed_pages, function($a, $b) {
4689 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4690 - });
4691 -
4692 - foreach ($sorted_failed_pages as $item) {
4693 - // Ensure $item is an array before accessing its elements
4694 - if (!is_array($item)) {
4695 - continue;
4696 - }
4697 -
4698 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4699 - $html .= '<tr>';
4700 - $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
4701 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4702 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4703 - $html .= '<td>' . esc_html($time_ago) . '</td>';
4704 - $html .= '</tr>';
4705 - }
4706 -
4707 - $html .= '</tbody></table>';
4708 - $html .= '</div></details></div></div>';
4709 -
4710 - return $html;
4711 -}
4712 -
4713 -/**
4714 - * Render failed URLs list
4715 - */
4716 -private function mxchat_render_failed_urls_list($failed_urls_list) {
4717 - // Validate that $failed_urls_list is an array and not empty
4718 - if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
4719 - return '';
4720 - }
4721 -
4722 - $html = '<div class="mxchat-failed-urls-container">';
4723 - $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
4724 - $html .= '<details>';
4725 - $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
4726 - $html .= '<div class="mxchat-failed-urls-list">';
4727 -
4728 - // Create table for failed URLs
4729 - $html .= '<table class="widefat striped">';
4730 - $html .= '<thead><tr>';
4731 - $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
4732 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4733 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4734 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4735 - $html .= '</tr></thead><tbody>';
4736 -
4737 - // Sort failed URLs by most recent
4738 - $sorted_failed_urls = $failed_urls_list;
4739 - usort($sorted_failed_urls, function($a, $b) {
4740 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4741 - });
4742 -
4743 - // Show up to 50 failed URLs
4744 - $display_urls = array_slice($sorted_failed_urls, 0, 50);
4745 -
4746 - foreach ($display_urls as $item) {
4747 - // Ensure $item is an array before accessing its elements
4748 - if (!is_array($item)) {
4749 - continue;
4750 - }
4751 -
4752 - $url = $item['url'] ?? '';
4753 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4754 -
4755 - // Truncate URL for display
4756 - $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
4757 -
4758 - $html .= '<tr>';
4759 - $html .= '<td style="word-break: break-all;">';
4760 - if (!empty($url)) {
4761 - $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
4762 - } else {
4763 - $html .= esc_html__('Unknown URL', 'mxchat');
4764 - }
4765 - $html .= '</td>';
4766 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4767 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4768 - $html .= '<td>' . esc_html($time_ago) . '</td>';
4769 - $html .= '</tr>';
4770 - }
4771 -
4772 - $html .= '</tbody></table>';
4773 -
4774 - if (count($failed_urls_list) > 50) {
4775 - $html .= '<div class="mxchat-failed-urls-more">+ ' .
4776 - (count($failed_urls_list) - 50) .
4777 - ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
4778 - }
4779 -
4780 - $html .= '</div></details></div>';
4781 -
4782 - return $html;
4783 -}
4784 -
4785 -/**
4786 - * Get all ACF fields for a specific post, excluding any fields the user has disabled
4787 - */
4788 -public function mxchat_get_acf_fields_for_post($post_id) {
4789 - if (!function_exists('get_fields')) {
4790 - return array();
4791 - }
4792 -
4793 - $fields = get_fields($post_id);
4794 - if (!$fields || !is_array($fields)) {
4795 - return array();
4796 - }
4797 -
4798 - // Get excluded fields from settings
4799 - $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
4800 - if (!empty($excluded_fields) && is_array($excluded_fields)) {
4801 - foreach ($excluded_fields as $excluded_field) {
4802 - if (isset($fields[$excluded_field])) {
4803 - unset($fields[$excluded_field]);
4804 - }
4805 - }
4806 - }
4807 -
4808 - return $fields;
4809 -}
4810 -
4811 -/**
4812 - * Get all registered ACF field groups and their fields for the settings UI
4813 - */
4814 -public function mxchat_get_all_acf_fields() {
4815 - if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
4816 - return array();
4817 - }
4818 -
4819 - $all_fields = array();
4820 - $field_groups = acf_get_field_groups();
4821 -
4822 - if (!empty($field_groups)) {
4823 - foreach ($field_groups as $group) {
4824 - $group_fields = acf_get_fields($group['key']);
4825 - if (!empty($group_fields)) {
4826 - $all_fields[$group['title']] = array();
4827 - foreach ($group_fields as $field) {
4828 - $all_fields[$group['title']][] = array(
4829 - 'name' => $field['name'],
4830 - 'label' => $field['label'],
4831 - 'type' => $field['type']
4832 - );
4833 - }
4834 - }
4835 - }
4836 - }
4837 -
4838 - return $all_fields;
4839 -}
4840 -
4841 -/**
4842 - * Get whitelisted custom post meta for a given post
4843 - * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
4844 - */
4845 -public function mxchat_get_whitelisted_post_meta($post_id) {
4846 - $whitelist = get_option('mxchat_custom_meta_whitelist', '');
4847 -
4848 - if (empty($whitelist)) {
4849 - return array();
4850 - }
4851 -
4852 - // Parse the whitelist - one meta key per line
4853 - $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
4854 -
4855 - if (empty($meta_keys)) {
4856 - return array();
4857 - }
4858 -
4859 - $result = array();
4860 -
4861 - foreach ($meta_keys as $key) {
4862 - // Skip empty keys
4863 - if (empty($key)) {
4864 - continue;
4865 - }
4866 -
4867 - $value = get_post_meta($post_id, $key, true);
4868 -
4869 - // Only include non-empty string values
4870 - if (!empty($value) && is_string($value)) {
4871 - $result[$key] = $value;
4872 - } elseif (!empty($value) && is_array($value)) {
4873 - // Handle array values by joining them
4874 - $flat_value = $this->mxchat_flatten_meta_array($value);
4875 - if (!empty($flat_value)) {
4876 - $result[$key] = $flat_value;
4877 - }
4878 - }
4879 - }
4880 -
4881 - return $result;
4882 -}
4883 -
4884 -/**
4885 - * Flatten array meta values into a readable string
4886 - */
4887 -private function mxchat_flatten_meta_array($array, $depth = 0) {
4888 - if ($depth > 3) {
4889 - return ''; // Prevent infinite recursion
4890 - }
4891 -
4892 - $parts = array();
4893 -
4894 - foreach ($array as $key => $value) {
4895 - if (is_string($value) && !empty($value)) {
4896 - $parts[] = $value;
4897 - } elseif (is_array($value)) {
4898 - $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
4899 - if (!empty($nested)) {
4900 - $parts[] = $nested;
4901 - }
4902 - }
4903 - }
4904 -
4905 - return implode(', ', $parts);
4906 -}
4907 -
4908 -/**
4909 - * Format ACF field values for content extraction
4910 - */
4911 -public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
4912 - if (empty($value)) {
4913 - return '';
4914 - }
4915 -
4916 - // Handle WP_Post objects first (THIS IS THE KEY FIX)
4917 - if ($value instanceof WP_Post) {
4918 - return $value->post_title ?: '';
4919 - }
4920 -
4921 - // Handle other WP objects
4922 - if (is_object($value)) {
4923 - if (isset($value->post_title)) {
4924 - return $value->post_title;
4925 - } elseif (isset($value->display_name)) {
4926 - return $value->display_name;
4927 - } elseif (isset($value->name)) {
4928 - return $value->name;
4929 - } elseif (method_exists($value, '__toString')) {
4930 - try {
4931 - return (string) $value;
4932 - } catch (Exception $e) {
4933 - return '';
4934 - }
4935 - }
4936 - // For any other objects, return empty string
4937 - return '';
4938 - }
4939 -
4940 - // Handle different ACF field types
4941 - if (is_array($value)) {
4942 - // Check if it's an image/file field
4943 - if (isset($value['url'])) {
4944 - // Image field - return alt text, title, or caption
4945 - if (!empty($value['alt'])) {
4946 - return $value['alt'];
4947 - } elseif (!empty($value['title'])) {
4948 - return $value['title'];
4949 - } elseif (!empty($value['caption'])) {
4950 - return $value['caption'];
4951 - } else {
4952 - return ''; // Don't include just the URL
4953 - }
4954 - }
4955 -
4956 - // Check if it's a post object or relationship field
4957 - if (isset($value['post_title'])) {
4958 - return $value['post_title'];
4959 - }
4960 -
4961 - // Check if it's a user field
4962 - if (isset($value['display_name'])) {
4963 - return $value['display_name'];
4964 - }
4965 -
4966 - // Check if it's a taxonomy term
4967 - if (isset($value['name']) && isset($value['taxonomy'])) {
4968 - return $value['name'];
4969 - }
4970 -
4971 - // Check if it's a select field with label
4972 - if (isset($value['label'])) {
4973 - return $value['label'];
4974 - }
4975 -
4976 - // Check for repeater field or flexible content
4977 - if (is_numeric(key($value))) {
4978 - $sub_values = array();
4979 - foreach ($value as $sub_item) {
4980 - if (is_array($sub_item)) {
4981 - // For repeater/flexible content, extract text values
4982 - $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
4983 - if (!empty($sub_text)) {
4984 - $sub_values[] = $sub_text;
4985 - }
4986 - } elseif ($sub_item instanceof WP_Post) {
4987 - // Handle WP_Post objects in arrays
4988 - $sub_values[] = $sub_item->post_title ?: '';
4989 - } else {
4990 - $sub_values[] = (string) $sub_item;
4991 - }
4992 - }
4993 - return implode(', ', array_filter($sub_values));
4994 - }
4995 -
4996 - // For other arrays, try to extract meaningful text
4997 - $text_values = array();
4998 - foreach ($value as $key => $val) {
4999 - if (is_string($val) && !empty(trim($val))) {
5000 - $text_values[] = trim($val);
5001 - } elseif ($val instanceof WP_Post) {
5002 - // Handle WP_Post objects in associative arrays
5003 - $text_values[] = $val->post_title ?: '';
5004 - } elseif (is_array($val) && isset($val['post_title'])) {
5005 - $text_values[] = $val['post_title'];
5006 - } elseif (is_array($val) && isset($val['name'])) {
5007 - $text_values[] = $val['name'];
5008 - }
5009 - }
5010 -
5011 - return implode(', ', array_filter($text_values));
5012 - }
5013 -
5014 - // Handle boolean values
5015 - if (is_bool($value)) {
5016 - return $value ? 'Yes' : 'No';
5017 - }
5018 -
5019 - // Handle numeric values
5020 - if (is_numeric($value)) {
5021 - return (string) $value;
5022 - }
5023 -
5024 - // Handle string values
5025 - if (is_string($value)) {
5026 - return trim($value);
5027 - }
5028 -
5029 - // For anything else that we can't handle, return empty string
5030 - // This prevents the "Object could not be converted to string" error
5031 - return '';
5032 -}
5033 -
5034 -/**
5035 - * Extract text from complex ACF array structures
5036 - */
5037 -private function mxchat_extract_text_from_acf_array($array) {
5038 - if (!is_array($array)) {
5039 - return '';
5040 - }
5041 -
5042 - $text_parts = array();
5043 -
5044 - foreach ($array as $key => $value) {
5045 - if (is_string($value) && !empty(trim($value))) {
5046 - // Skip keys that are likely to be IDs or technical values
5047 - if (!is_numeric($value) || strlen($value) > 10) {
5048 - $text_parts[] = trim($value);
5049 - }
5050 - } elseif ($value instanceof WP_Post) {
5051 - // Handle WP_Post objects
5052 - $text_parts[] = $value->post_title ?: '';
5053 - } elseif (is_array($value)) {
5054 - if (isset($value['post_title'])) {
5055 - $text_parts[] = $value['post_title'];
5056 - } elseif (isset($value['name'])) {
5057 - $text_parts[] = $value['name'];
5058 - } elseif (isset($value['label'])) {
5059 - $text_parts[] = $value['label'];
5060 - }
5061 - } elseif (is_object($value)) {
5062 - // Handle other objects safely
5063 - if (isset($value->post_title)) {
5064 - $text_parts[] = $value->post_title;
5065 - } elseif (isset($value->name)) {
5066 - $text_parts[] = $value->name;
5067 - } elseif (isset($value->display_name)) {
5068 - $text_parts[] = $value->display_name;
5069 - }
5070 - }
5071 - }
5072 -
5073 - return implode(', ', array_filter($text_parts));
5074 -}
5075 -
5076 -/**
5077 - * Handle ACF save - fires after ACF fields are saved
5078 - * This ensures ACF field data is available when syncing to knowledge base
5079 - */
5080 -public function mxchat_handle_acf_save($post_id) {
5081 - // Skip if not a valid post
5082 - if (!$post_id || $post_id === 'options') {
5083 - return;
5084 - }
5085 -
5086 - // Skip autosaves and revisions
5087 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5088 - return;
5089 - }
5090 -
5091 - $post = get_post($post_id);
5092 - if (!$post) {
5093 - return;
5094 - }
5095 -
5096 - $post_type = $post->post_type;
5097 -
5098 - // Check if sync is enabled for this post type
5099 - $should_sync = false;
5100 -
5101 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5102 - $should_sync = true;
5103 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5104 - $should_sync = true;
5105 - } else if ($post_type === 'product' && class_exists('WooCommerce')) {
5106 - // WooCommerce products - check if WooCommerce integration is enabled
5107 - $options = get_option('mxchat_options', array());
5108 - if (isset($options['enable_woocommerce_integration']) &&
5109 - ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
5110 - $should_sync = true;
5111 - }
5112 - } else {
5113 - // Check custom post types
5114 - $option_name = 'mxchat_auto_sync_' . $post_type;
5115 - if (get_option($option_name) === '1') {
5116 - $should_sync = true;
5117 - }
5118 - }
5119 -
5120 - if (!$should_sync) {
5121 - return;
5122 - }
5123 -
5124 - // Only process published posts
5125 - if ($post->post_status !== 'publish') {
5126 - return;
5127 - }
5128 -
5129 - // Check if this post has any ACF fields - if not, no need to re-sync
5130 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5131 - if (empty($acf_fields)) {
5132 - return;
5133 - }
5134 -
5135 - // Use a transient to prevent duplicate processing (post_updated may have already run)
5136 - $transient_key = 'mxchat_acf_synced_' . $post_id;
5137 - if (get_transient($transient_key)) {
5138 - return;
5139 - }
5140 - set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
5141 -
5142 - // Re-run the sync with ACF data now available
5143 - // We pass $update=true since this is effectively an update with ACF data
5144 - $this->mxchat_handle_post_update($post_id, $post, true);
5145 -}
5146 -
5147 -public function mxchat_handle_post_update($post_id, $post, $update) {
5148 - // Basic validation checks
5149 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5150 - return;
5151 - }
5152 -
5153 - $post_type = $post->post_type;
5154 -
5155 - // Check if sync is enabled for this post type
5156 - $should_sync = false;
5157 -
5158 - // Check built-in post types first
5159 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5160 - $should_sync = true;
5161 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5162 - $should_sync = true;
5163 - } else {
5164 - // Check custom post types
5165 - $option_name = 'mxchat_auto_sync_' . $post_type;
5166 - if (get_option($option_name) === '1') {
5167 - $should_sync = true;
5168 - }
5169 - }
5170 -
5171 - if (!$should_sync) {
5172 - return;
5173 - }
5174 -
5175 - // Check if we have stored the previous status and URL in our transients
5176 - $previous_status_key = 'mxchat_prev_status_' . $post_id;
5177 - $previous_status = get_transient($previous_status_key);
5178 -
5179 - $previous_url_key = 'mxchat_prev_url_' . $post_id;
5180 - $previous_url = get_transient($previous_url_key);
5181 -
5182 - // If the post was previously published but is now not published, remove from knowledge base
5183 - if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5184 - // Use the stored URL from when it was published, or fall back to current permalink
5185 - $source_url = $previous_url ?: get_permalink($post_id);
5186 -
5187 - if ($source_url) {
5188 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5189 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5190 - }
5191 -
5192 - // Clean up the transients and exit early
5193 - delete_transient($previous_status_key);
5194 - delete_transient($previous_url_key);
5195 - return;
5196 - }
5197 -
5198 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5199 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5200 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5201 - $current_url = get_permalink($post_id);
5202 - if ($current_url && $current_url !== $previous_url) {
5203 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5204 - }
5205 - }
5206 -
5207 - // Store the current status for next time (if this is an update)
5208 - if ($update) {
5209 - set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5210 -
5211 - // If the post is currently published, also store its URL
5212 - if ($post->post_status === 'publish') {
5213 - $current_url = get_permalink($post_id);
5214 - set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
5215 - }
5216 - }
5217 -
5218 - // Only process currently published content for adding/updating
5219 - if ($post->post_status === 'publish') {
5220 - // Get the source URL
5221 - $source_url = get_permalink($post_id);
5222 -
5223 - // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
5224 - $title = get_the_title($post_id);
5225 - $content = get_post_field('post_content', $post_id);
5226 - $excerpt = get_post_field('post_excerpt', $post_id);
5227 -
5228 - // Remove shortcode tags but preserve content inside them
5229 - $content = $this->strip_shortcode_tags_preserve_content($content);
5230 - $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
5231 -
5232 - // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
5233 - $content = wp_strip_all_tags($content);
5234 -
5235 - // Combine title, short description (if exists), and content
5236 - $final_content = $title . "\n\n";
5237 -
5238 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
5239 - if (!empty($excerpt)) {
5240 - $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
5241 - }
5242 -
5243 - $final_content .= $content;
5244 -
5245 - // For WooCommerce products, include pricing and product details
5246 - if ($post_type === 'product' && class_exists('WooCommerce')) {
5247 - $product = wc_get_product($post_id);
5248 -
5249 - if ($product) {
5250 - // Get pricing information
5251 - $regular_price = $product->get_regular_price();
5252 - $sale_price = $product->get_sale_price();
5253 - $price = $product->get_price();
5254 - $sku = $product->get_sku();
5255 -
5256 - // Get currency symbol
5257 - $currency_symbol = get_woocommerce_currency_symbol();
5258 -
5259 - // Add pricing information
5260 - $final_content .= "\n";
5261 - if (!empty($regular_price)) {
5262 - $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
5263 - } elseif (!empty($price)) {
5264 - $final_content .= "Price: " . $currency_symbol . $price . "\n";
5265 - }
5266 -
5267 - if (!empty($sale_price) && $sale_price !== $regular_price) {
5268 - $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5269 - }
5270 -
5271 - // Handle variable products - show price range
5272 - if ($product->is_type('variable')) {
5273 - $min_price = $product->get_variation_price('min');
5274 - $max_price = $product->get_variation_price('max');
5275 - if ($min_price !== $max_price) {
5276 - $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5277 - }
5278 - }
5279 -
5280 - if (!empty($sku)) {
5281 - $final_content .= "SKU: " . $sku . "\n";
5282 - }
5283 -
5284 - // Get product categories
5285 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
5286 - if (!empty($categories) && !is_wp_error($categories)) {
5287 - $final_content .= "Categories: " . implode(', ', $categories) . "\n";
5288 - }
5289 - }
5290 - }
5291 -
5292 - // For custom post types like job_listing, include additional fields
5293 - if ($post_type === 'job_listing') {
5294 - // Add job-specific meta if available
5295 - $job_location = get_post_meta($post_id, '_job_location', true);
5296 - if (!empty($job_location)) {
5297 - $final_content .= "\n\nLocation: " . $job_location;
5298 - }
5299 -
5300 - // Get job type terms
5301 - $job_types = get_the_terms($post_id, 'job_listing_type');
5302 - if (!empty($job_types) && !is_wp_error($job_types)) {
5303 - $types = array();
5304 - foreach ($job_types as $type) {
5305 - $types[] = $type->name;
5306 - }
5307 - $final_content .= "\n\nJob Type: " . implode(', ', $types);
5308 - }
5309 -
5310 - // Get company name if available
5311 - $company_name = get_post_meta($post_id, '_company_name', true);
5312 - if (!empty($company_name)) {
5313 - $final_content .= "\n\nCompany: " . $company_name;
5314 - }
5315 - }
5316 -
5317 - // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5318 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5319 - if (!empty($acf_fields)) {
5320 - $acf_content_parts = array();
5321 -
5322 - foreach ($acf_fields as $field_name => $field_value) {
5323 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5324 - if (!empty($formatted_value)) {
5325 - // Convert field name to readable label
5326 - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5327 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
5328 - }
5329 - }
5330 -
5331 - if (!empty($acf_content_parts)) {
5332 - $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5333 - }
5334 - }
5335 -
5336 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5337 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
5338 - if (!empty($custom_meta)) {
5339 - $meta_content_parts = array();
5340 -
5341 - foreach ($custom_meta as $meta_key => $meta_value) {
5342 - // Convert meta key to readable label
5343 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
5344 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
5345 - }
5346 -
5347 - if (!empty($meta_content_parts)) {
5348 - $final_content .= "\n\n" . implode("\n", $meta_content_parts);
5349 - }
5350 - }
5351 -
5352 - // Get API key with proper model detection
5353 - $options = get_option('mxchat_options');
5354 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5355 -
5356 - if (strpos($selected_model, 'voyage') === 0) {
5357 - $api_key = $options['voyage_api_key'] ?? '';
5358 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5359 - $api_key = $options['gemini_api_key'] ?? '';
5360 - } else {
5361 - $api_key = $options['api_key'] ?? '';
5362 - }
5363 -
5364 - if (empty($api_key)) {
5365 - return;
5366 - }
5367 -
5368 - // Use the centralized utility function for storage
5369 - $result = MxChat_Utils::submit_content_to_db(
5370 - $final_content,
5371 - $source_url,
5372 - $api_key,
5373 - md5($source_url) // Vector ID for Pinecone
5374 - );
5375 -
5376 - // After successful storage, apply role restriction based on tags
5377 - if (!is_wp_error($result)) {
5378 - $this->apply_role_restriction_to_post($post_id, $source_url);
5379 - }
5380 - }
5381 -
5382 - // Clean up the stored previous status if not used above
5383 - if ($previous_status !== 'publish' || $post->post_status === 'publish') {
5384 - delete_transient($previous_status_key);
5385 - delete_transient($previous_url_key);
5386 - }
5387 -}
5388 -
5389 -/**
5390 - * Store the post status and URL before update to detect status transitions
5391 - * This runs before the post is actually updated in the database
5392 - */
5393 -public function mxchat_store_pre_update_status($post_id, $data) {
5394 - // Get the current post from database (before update)
5395 - $current_post = get_post($post_id);
5396 -
5397 - if ($current_post) {
5398 - // Store the current status temporarily
5399 - $status_key = 'mxchat_prev_status_' . $post_id;
5400 - set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
5401 -
5402 - // If the post is currently published, also store its URL
5403 - if ($current_post->post_status === 'publish') {
5404 - $url_key = 'mxchat_prev_url_' . $post_id;
5405 - $current_url = get_permalink($post_id);
5406 - set_transient($url_key, $current_url, HOUR_IN_SECONDS);
5407 - }
5408 - }
5409 -}
5410 -
5411 -public function mxchat_handle_post_delete($post_id) {
5412 - // Get post data before it's deleted
5413 - $post = get_post($post_id);
5414 -
5415 - // Basic validation
5416 - if (!$post || wp_is_post_revision($post_id)) {
5417 - return;
5418 - }
5419 -
5420 - $post_type = $post->post_type;
5421 -
5422 - // Check if sync is enabled for this post type
5423 - $should_sync = false;
5424 -
5425 - // Check built-in post types first
5426 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5427 - $should_sync = true;
5428 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5429 - $should_sync = true;
5430 - } else {
5431 - // Check custom post types
5432 - $option_name = 'mxchat_auto_sync_' . $post_type;
5433 - if (get_option($option_name) === '1') {
5434 - $should_sync = true;
5435 - }
5436 - }
5437 -
5438 - if (!$should_sync) {
5439 - return;
5440 - }
5441 -
5442 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
5443 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
5444 - // real vector IDs stored under the original URL.
5445 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
5446 - if (!$source_url) {
5447 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5448 - return;
5449 - }
5450 -
5451 - // Use chunk-aware deletion (handles both chunked and non-chunked content)
5452 - $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5453 -
5454 - if (is_wp_error($delete_result)) {
5455 - //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5456 - }
5457 -
5458 - delete_transient('mxchat_prev_url_' . $post_id);
5459 - delete_transient('mxchat_prev_status_' . $post_id);
5460 -}
5461 -
5462 -/**
5463 - * Resolve the source URL for a post being trashed/deleted.
5464 - *
5465 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
5466 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
5467 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
5468 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
5469 - */
5470 -private function mxchat_resolve_pre_trash_url($post_id) {
5471 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
5472 - if (!empty($previous_url)) {
5473 - return $previous_url;
5474 - }
5475 -
5476 - $current = get_permalink($post_id);
5477 - if (!$current) {
5478 - return '';
5479 - }
5480 - return preg_replace('#__trashed(/?)$#', '$1', $current);
5481 -}
5482 -
5483 -
5484 -
5485 -public function mxchat_handle_product_change($post_id, $post, $update) {
5486 - if ($post->post_type !== 'product') {
5487 - return;
5488 - }
5489 -
5490 - if ($post->post_status === 'publish') {
5491 - add_action('shutdown', function() use ($post_id) {
5492 - $product = wc_get_product($post_id);
5493 - if ($product) {
5494 - $this->mxchat_store_product_embedding($product);
5495 - }
5496 - });
5497 - }
5498 -}
5499 -
5500 -/**
5501 - * Store WooCommerce product embeddings
5502 - */
5503 -private function mxchat_store_product_embedding($product) {
5504 - if (!isset($this->options['enable_woocommerce_integration']) ||
5505 - !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
5506 - return;
5507 - }
5508 -
5509 - $source_url = get_permalink($product->get_id());
5510 - $product_id = $product->get_id();
5511 -
5512 - // Build product content
5513 - $title = $product->get_name();
5514 - $description = $product->get_description();
5515 - $short_description = $product->get_short_description();
5516 - $regular_price = $product->get_regular_price();
5517 - $sale_price = $product->get_sale_price();
5518 - $price = $product->get_price();
5519 - $sku = $product->get_sku();
5520 -
5521 - // Get currency symbol
5522 - $currency_symbol = get_woocommerce_currency_symbol();
5523 -
5524 - // Format content consistently
5525 - $content = $title . "\n\n";
5526 -
5527 - if (!empty($short_description)) {
5528 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
5529 - }
5530 -
5531 - if (!empty($description)) {
5532 - $content .= wp_strip_all_tags($description) . "\n\n";
5533 - }
5534 -
5535 - // Add pricing information
5536 - if (!empty($regular_price)) {
5537 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
5538 - } elseif (!empty($price)) {
5539 - $content .= "Price: " . $currency_symbol . $price . "\n";
5540 - }
5541 -
5542 - if (!empty($sale_price) && $sale_price !== $regular_price) {
5543 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5544 - }
5545 -
5546 - // Handle variable products - show price range
5547 - if ($product->is_type('variable')) {
5548 - $min_price = $product->get_variation_price('min');
5549 - $max_price = $product->get_variation_price('max');
5550 - if ($min_price !== $max_price) {
5551 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5552 - }
5553 - }
5554 -
5555 - if (!empty($sku)) {
5556 - $content .= "SKU: " . $sku . "\n";
5557 - }
5558 -
5559 - // Get product categories
5560 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
5561 - if (!empty($categories) && !is_wp_error($categories)) {
5562 - $content .= "Categories: " . implode(', ', $categories) . "\n";
5563 - }
5564 -
5565 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
5566 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
5567 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
5568 - foreach ($custom_tabs as $tab) {
5569 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5570 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
5571 -
5572 - if (!empty($tab_title) && !empty($tab_content)) {
5573 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5574 - }
5575 - }
5576 - }
5577 -
5578 - // Also check for reusable/saved tabs applied to this product
5579 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
5580 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
5581 - // Get the saved tabs option
5582 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
5583 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
5584 - foreach ($applied_saved_tabs as $saved_tab_id) {
5585 - if (isset($saved_tabs[$saved_tab_id])) {
5586 - $tab = $saved_tabs[$saved_tab_id];
5587 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5588 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
5589 -
5590 - if (!empty($tab_title) && !empty($tab_content)) {
5591 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5592 - }
5593 - }
5594 - }
5595 - }
5596 - }
5597 -
5598 - // Get API key with proper model detection
5599 - $options = get_option('mxchat_options');
5600 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5601 -
5602 - if (strpos($selected_model, 'voyage') === 0) {
5603 - $api_key = $options['voyage_api_key'] ?? '';
5604 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5605 - $api_key = $options['gemini_api_key'] ?? '';
5606 - } else {
5607 - $api_key = $options['api_key'] ?? '';
5608 - }
5609 -
5610 - if (empty($api_key)) {
5611 - //error_log('MxChat Auto-sync: No API key configured for embedding model');
5612 - return;
5613 - }
5614 -
5615 - // Use the centralized utility function for storage
5616 - $result = MxChat_Utils::submit_content_to_db(
5617 - $content,
5618 - $source_url,
5619 - $api_key,
5620 - md5($source_url) // Vector ID for Pinecone
5621 - );
5622 -
5623 - // After successful storage, apply role restriction based on tags
5624 - if (!is_wp_error($result)) {
5625 - $this->apply_role_restriction_to_post($product_id, $source_url);
5626 - }
5627 -
5628 - if (is_wp_error($result)) {
5629 - //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
5630 - }
5631 -}
5632 -
5633 -public function mxchat_handle_product_delete($post_id) {
5634 - if (get_post_type($post_id) !== 'product') {
5635 - return;
5636 - }
5637 -
5638 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
5639 - if (!$source_url) {
5640 - return;
5641 - }
5642 -
5643 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5644 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5645 -
5646 - delete_transient('mxchat_prev_url_' . $post_id);
5647 - delete_transient('mxchat_prev_status_' . $post_id);
5648 -}
5649 -
5650 -/**
5651 - * Handle individual Pinecone content deletion
5652 - */
5653 -public function mxchat_handle_pinecone_prompt_delete() {
5654 - // Check permissions
5655 - if (!current_user_can('manage_options')) {
5656 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
5657 - }
5658 -
5659 - // Verify nonce
5660 - if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
5661 - wp_die(esc_html__('Security check failed.', 'mxchat'));
5662 - }
5663 -
5664 - $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
5665 -
5666 - if (empty($vector_id)) {
5667 - set_transient('mxchat_admin_notice_error',
5668 - esc_html__('Invalid vector ID.', 'mxchat'),
5669 - 30
5670 - );
5671 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5672 - exit;
5673 - }
5674 -
5675 - // Get Pinecone settings
5676 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5677 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5678 -
5679 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5680 - set_transient('mxchat_admin_notice_error',
5681 - esc_html__('Pinecone is not properly configured.', 'mxchat'),
5682 - 30
5683 - );
5684 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5685 - exit;
5686 - }
5687 -
5688 - // Delete from Pinecone
5689 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5690 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5691 - $vector_id,
5692 - $pinecone_options['mxchat_pinecone_api_key'],
5693 - $pinecone_options['mxchat_pinecone_host']
5694 - );
5695 -
5696 - if ($result['success']) {
5697 - // No cache clearing needed since we removed caching
5698 - set_transient('mxchat_admin_notice_success',
5699 - esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
5700 - 30
5701 - );
5702 - } else {
5703 - set_transient('mxchat_admin_notice_error',
5704 - esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
5705 - 30
5706 - );
5707 - }
5708 -
5709 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5710 - exit;
5711 -}
5712 -/**
5713 - * Handle individual Pinecone content deletion via AJAX
5714 - */
5715 -public function ajax_mxchat_delete_pinecone_prompt() {
5716 - // Verify nonce and permissions
5717 - if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
5718 - wp_send_json_error('Invalid nonce');
5719 - exit;
5720 - }
5721 -
5722 - if (!current_user_can('manage_options')) {
5723 - wp_send_json_error('Unauthorized access');
5724 - exit;
5725 - }
5726 -
5727 - $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
5728 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5729 -
5730 - if (empty($vector_id)) {
5731 - wp_send_json_error('Missing vector ID');
5732 - exit;
5733 - }
5734 -
5735 - // Get bot-specific Pinecone settings
5736 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5737 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5738 -
5739 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5740 -
5741 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5742 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5743 - exit;
5744 - }
5745 -
5746 - // Delete from the correct Pinecone index
5747 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5748 - $vector_id,
5749 - $pinecone_options['mxchat_pinecone_api_key'],
5750 - $pinecone_options['mxchat_pinecone_host']
5751 - );
5752 -
5753 - if ($result['success']) {
5754 - // No cache clearing needed since we removed caching
5755 - wp_send_json_success(array(
5756 - 'message' => 'Entry deleted successfully from Pinecone',
5757 - 'vector_id' => $vector_id,
5758 - 'bot_id' => $bot_id
5759 - ));
5760 - } else {
5761 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
5762 - wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
5763 - }
5764 -
5765 - exit;
5766 -}
5767 -
5768 -/**
5769 - * Handle deletion of all chunks for a given source URL via AJAX
5770 - * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
5771 - */
5772 -public function ajax_mxchat_delete_chunks_by_url() {
5773 - // Verify nonce and permissions
5774 - if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
5775 - wp_send_json_error('Invalid nonce');
5776 - exit;
5777 - }
5778 -
5779 - if (!current_user_can('manage_options')) {
5780 - wp_send_json_error('Unauthorized access');
5781 - exit;
5782 - }
5783 -
5784 - $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
5785 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
5786 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5787 -
5788 - if (empty($source_url)) {
5789 - wp_send_json_error('Missing source URL');
5790 - exit;
5791 - }
5792 -
5793 - // Generate the base vector ID from the source URL (same as how chunks are created)
5794 - $base_vector_id = md5($source_url);
5795 -
5796 - if ($data_source === 'pinecone') {
5797 - // Get bot-specific Pinecone settings (same as working delete function)
5798 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5799 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5800 -
5801 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5802 -
5803 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5804 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5805 - exit;
5806 - }
5807 -
5808 - $api_key = $pinecone_options['mxchat_pinecone_api_key'];
5809 - $host = $pinecone_options['mxchat_pinecone_host'];
5810 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
5811 -
5812 - // Collect all vector IDs to delete
5813 - $vectors_to_delete = array();
5814 -
5815 - // Add the original single-vector ID (for non-chunked content)
5816 - $vectors_to_delete[] = $base_vector_id;
5817 -
5818 - // Use Pinecone list API to find all chunk vectors with this prefix
5819 - // NOTE: Pinecone List API is a GET request with query parameters, not POST
5820 - $prefix = $base_vector_id . '_chunk_';
5821 -
5822 - $query_params = array(
5823 - 'prefix' => $prefix,
5824 - 'limit' => 100
5825 - );
5826 -
5827 - if (!empty($namespace)) {
5828 - $query_params['namespace'] = $namespace;
5829 - }
5830 -
5831 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
5832 -
5833 - $list_response = wp_remote_get($list_url, array(
5834 - 'headers' => array(
5835 - 'Api-Key' => $api_key,
5836 - 'accept' => 'application/json'
5837 - ),
5838 - 'timeout' => 30
5839 - ));
5840 -
5841 - if (!is_wp_error($list_response)) {
5842 - $list_body_response = wp_remote_retrieve_body($list_response);
5843 - $list_data = json_decode($list_body_response, true);
5844 - if (!empty($list_data['vectors'])) {
5845 - foreach ($list_data['vectors'] as $vector) {
5846 - if (isset($vector['id'])) {
5847 - $vectors_to_delete[] = $vector['id'];
5848 - }
5849 - }
5850 - }
5851 - }
5852 -
5853 - if (empty($vectors_to_delete)) {
5854 - wp_send_json_success(array(
5855 - 'message' => 'No vectors found to delete',
5856 - 'source_url' => $source_url
5857 - ));
5858 - exit;
5859 - }
5860 -
5861 - // Delete all vectors using the same endpoint as the working function
5862 - $delete_url = "https://{$host}/vectors/delete";
5863 -
5864 - $delete_body = array(
5865 - 'ids' => $vectors_to_delete
5866 - );
5867 -
5868 - if (!empty($namespace)) {
5869 - $delete_body['namespace'] = $namespace;
5870 - }
5871 -
5872 - $delete_response = wp_remote_post($delete_url, array(
5873 - 'headers' => array(
5874 - 'Api-Key' => $api_key,
5875 - 'accept' => 'application/json',
5876 - 'content-type' => 'application/json'
5877 - ),
5878 - 'body' => wp_json_encode($delete_body),
5879 - 'timeout' => 30
5880 - ));
5881 -
5882 - if (is_wp_error($delete_response)) {
5883 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
5884 - wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
5885 - exit;
5886 - }
5887 -
5888 - $response_code = wp_remote_retrieve_response_code($delete_response);
5889 -
5890 - if ($response_code !== 200) {
5891 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
5892 - wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
5893 - exit;
5894 - }
5895 -
5896 - wp_send_json_success(array(
5897 - 'message' => 'All chunks deleted successfully from Pinecone',
5898 - 'source_url' => $source_url,
5899 - 'deleted_count' => count($vectors_to_delete)
5900 - ));
5901 -
5902 - } else {
5903 - // WordPress database deletion
5904 - global $wpdb;
5905 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5906 -
5907 - $result = $wpdb->delete(
5908 - $table_name,
5909 - array('source_url' => $source_url),
5910 - array('%s')
5911 - );
5912 -
5913 - if ($result === false) {
5914 - MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
5915 - wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
5916 - exit;
5917 - }
5918 -
5919 - wp_send_json_success(array(
5920 - 'message' => 'All chunks deleted successfully from database',
5921 - 'source_url' => $source_url,
5922 - 'deleted_count' => $result
5923 - ));
5924 - }
5925 -
5926 - exit;
5927 -}
5928 -
5929 -/**
5930 - * Handle individual WordPress database content deletion via AJAX
5931 - * Mirrors the Pinecone delete handler but for WordPress database entries
5932 - */
5933 -public function ajax_mxchat_delete_wordpress_prompt() {
5934 - // Verify nonce and permissions
5935 - if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
5936 - wp_send_json_error('Invalid nonce');
5937 - exit;
5938 - }
5939 -
5940 - if (!current_user_can('manage_options')) {
5941 - wp_send_json_error('Unauthorized access');
5942 - exit;
5943 - }
5944 -
5945 - $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
5946 -
5947 - if (empty($entry_id)) {
5948 - wp_send_json_error('Missing entry ID');
5949 - exit;
5950 - }
5951 -
5952 - global $wpdb;
5953 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5954 -
5955 - // Clear cache for this entry
5956 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5957 -
5958 - // Delete from database
5959 - $result = $wpdb->delete(
5960 - $table_name,
5961 - array('id' => $entry_id),
5962 - array('%d')
5963 - );
5964 -
5965 - if ($result !== false) {
5966 - wp_send_json_success(array(
5967 - 'message' => 'Entry deleted successfully',
5968 - 'entry_id' => $entry_id
5969 - ));
5970 - } else {
5971 - wp_send_json_error('Failed to delete entry from database');
5972 - }
5973 -
5974 - exit;
5975 -}
5976 -
5977 -/**
5978 - * Handle bulk deletion of knowledge entries via AJAX
5979 - * Supports both Pinecone and WordPress database entries
5980 - */
5981 -public function ajax_mxchat_bulk_delete_knowledge() {
5982 - // Verify nonce and permissions
5983 - if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
5984 - wp_send_json_error('Invalid nonce');
5985 - exit;
5986 - }
5987 -
5988 - if (!current_user_can('manage_options')) {
5989 - wp_send_json_error('Unauthorized access');
5990 - exit;
5991 - }
5992 -
5993 - $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
5994 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5995 -
5996 - if (empty($entries) || !is_array($entries)) {
5997 - wp_send_json_error('No entries provided');
5998 - exit;
5999 - }
6000 -
6001 - // Extend execution time — bulk Pinecone operations can take a while
6002 - if (function_exists('set_time_limit')) {
6003 - set_time_limit(120);
6004 - }
6005 -
6006 - $success_ids = array();
6007 - $failed_ids = array();
6008 - $errors = array();
6009 -
6010 - global $wpdb;
6011 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6012 -
6013 - // Get Pinecone manager for Pinecone deletions
6014 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6015 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6016 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6017 -
6018 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
6019 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
6020 -
6021 - // =============================================
6022 - // PHASE 1: Collect all Pinecone vector IDs
6023 - // and separate WordPress entries
6024 - // =============================================
6025 - $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
6026 - $wordpress_entries = array(); // entries for WordPress DB deletion
6027 - $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
6028 -
6029 - foreach ($entries as $entry) {
6030 - $entry_id = sanitize_text_field($entry['id'] ?? '');
6031 - $source = sanitize_text_field($entry['source'] ?? 'wordpress');
6032 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
6033 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
6034 -
6035 - if (empty($entry_id)) {
6036 - continue;
6037 - }
6038 -
6039 - if ($source === 'pinecone') {
6040 - if (!$use_pinecone || empty($api_key)) {
6041 - $failed_ids[] = $entry_id;
6042 - $errors[] = "Pinecone not configured for entry: $entry_id";
6043 - continue;
6044 - }
6045 -
6046 - $pinecone_entry_ids[] = $entry_id;
6047 -
6048 - if ($is_group && !empty($source_url)) {
6049 - // Grouped/chunked entry: collect base ID + chunk IDs via List API
6050 - $base_vector_id = md5($source_url);
6051 - $all_vector_ids[] = $base_vector_id;
6052 -
6053 - $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
6054 - $list_response = wp_remote_get($list_url, array(
6055 - 'headers' => array(
6056 - 'Api-Key' => $api_key,
6057 - 'accept' => 'application/json'
6058 - ),
6059 - 'timeout' => 30
6060 - ));
6061 -
6062 - if (!is_wp_error($list_response)) {
6063 - $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
6064 - if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
6065 - foreach ($list_body['vectors'] as $vector) {
6066 - if (isset($vector['id'])) {
6067 - $all_vector_ids[] = $vector['id'];
6068 - }
6069 - }
6070 - }
6071 - }
6072 - } else {
6073 - // Single entry: the entry_id IS the vector ID
6074 - $all_vector_ids[] = $entry_id;
6075 - }
6076 - } else {
6077 - $wordpress_entries[] = $entry;
6078 - }
6079 - }
6080 -
6081 - // =============================================
6082 - // PHASE 2: Single batch delete to Pinecone
6083 - // =============================================
6084 - if (!empty($all_vector_ids)) {
6085 - $all_vector_ids = array_values(array_unique($all_vector_ids));
6086 - $pinecone_success = true;
6087 - $batches = array_chunk($all_vector_ids, 100);
6088 -
6089 - foreach ($batches as $batch) {
6090 - $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
6091 - 'headers' => array(
6092 - 'Api-Key' => $api_key,
6093 - 'accept' => 'application/json',
6094 - 'content-type' => 'application/json'
6095 - ),
6096 - 'body' => wp_json_encode(array('ids' => $batch)),
6097 - 'timeout' => 60
6098 - ));
6099 -
6100 - if (is_wp_error($delete_response)) {
6101 - $pinecone_success = false;
6102 - $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
6103 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
6104 - } else {
6105 - $response_code = wp_remote_retrieve_response_code($delete_response);
6106 - if ($response_code !== 200) {
6107 - $pinecone_success = false;
6108 - $response_body = wp_remote_retrieve_body($delete_response);
6109 - $errors[] = "Pinecone API error (HTTP $response_code)";
6110 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
6111 - }
6112 - }
6113 - }
6114 -
6115 - // Mark all pinecone entries based on batch result
6116 - foreach ($pinecone_entry_ids as $eid) {
6117 - if ($pinecone_success) {
6118 - $success_ids[] = $eid;
6119 - } else {
6120 - $failed_ids[] = $eid;
6121 - }
6122 - }
6123 - }
6124 -
6125 - // =============================================
6126 - // PHASE 3: WordPress database deletions
6127 - // =============================================
6128 - foreach ($wordpress_entries as $entry) {
6129 - $entry_id = sanitize_text_field($entry['id'] ?? '');
6130 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
6131 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
6132 -
6133 - if (empty($entry_id)) {
6134 - continue;
6135 - }
6136 -
6137 - try {
6138 - if ($is_group && !empty($source_url)) {
6139 - $result = $wpdb->delete(
6140 - $table_name,
6141 - array('source_url' => $source_url),
6142 - array('%s')
6143 - );
6144 - } else {
6145 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
6146 - $result = $wpdb->delete(
6147 - $table_name,
6148 - array('id' => intval($entry_id)),
6149 - array('%d')
6150 - );
6151 - }
6152 -
6153 - if ($result !== false) {
6154 - $success_ids[] = $entry_id;
6155 - } else {
6156 - $failed_ids[] = $entry_id;
6157 - $errors[] = "Database error for entry: $entry_id";
6158 - }
6159 - } catch (Exception $e) {
6160 - $failed_ids[] = $entry_id;
6161 - $errors[] = $e->getMessage();
6162 - }
6163 - }
6164 -
6165 - wp_send_json_success(array(
6166 - 'success_ids' => $success_ids,
6167 - 'failed_ids' => $failed_ids,
6168 - 'errors' => $errors,
6169 - 'total_processed' => count($success_ids) + count($failed_ids)
6170 - ));
6171 -
6172 - exit;
6173 -}
6174 -
6175 -/**
6176 - * Get hierarchical roles for dropdown
6177 - */
6178 -public function mxchat_get_role_options() {
6179 - return array(
6180 - 'public' => __('Public (Everyone)', 'mxchat'),
6181 - 'logged_in' => __('Logged In Users', 'mxchat'),
6182 - 'subscriber' => __('Subscribers & Above', 'mxchat'),
6183 - 'contributor' => __('Contributors & Above', 'mxchat'),
6184 - 'author' => __('Authors & Above', 'mxchat'),
6185 - 'editor' => __('Editors & Above', 'mxchat'),
6186 - 'administrator' => __('Administrators Only', 'mxchat')
6187 - );
6188 -}
6189 -
6190 -/**
6191 - * Check if user has access to content based on role restriction
6192 - */
6193 -public function mxchat_user_has_content_access($role_restriction) {
6194 - // Public content is always accessible
6195 - if ($role_restriction === 'public' || empty($role_restriction)) {
6196 - return true;
6197 - }
6198 -
6199 - // Check if user is logged in for logged_in restriction
6200 - if ($role_restriction === 'logged_in') {
6201 - return is_user_logged_in();
6202 - }
6203 -
6204 - // If not logged in, no access to role-restricted content
6205 - if (!is_user_logged_in()) {
6206 - return false;
6207 - }
6208 -
6209 - $user = wp_get_current_user();
6210 - $user_roles = $user->roles;
6211 -
6212 - if (empty($user_roles)) {
6213 - return false;
6214 - }
6215 -
6216 - // Define role hierarchy (higher number = higher access)
6217 - $hierarchy = array(
6218 - 'subscriber' => 1,
6219 - 'contributor' => 2,
6220 - 'author' => 3,
6221 - 'editor' => 4,
6222 - 'administrator' => 5
6223 - );
6224 -
6225 - // Get required level
6226 - $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
6227 -
6228 - // Check if user has required level or higher
6229 - foreach ($user_roles as $user_role) {
6230 - $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
6231 - if ($user_level >= $required_level) {
6232 - return true;
6233 - }
6234 - }
6235 -
6236 - return false;
6237 -}
6238 -
6239 -/**
6240 - * Handle role restriction updates via AJAX
6241 - * Removed cache clearing call since we removed caching
6242 - */
6243 -public function ajax_mxchat_update_role_restriction() {
6244 - // Verify nonce and permissions
6245 - if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
6246 - wp_send_json_error('Invalid nonce');
6247 - exit;
6248 - }
6249 -
6250 - if (!current_user_can('manage_options')) {
6251 - wp_send_json_error('Unauthorized access');
6252 - exit;
6253 - }
6254 -
6255 - $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
6256 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6257 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
6258 -
6259 - if (empty($entry_id)) {
6260 - wp_send_json_error('Invalid entry ID');
6261 - exit;
6262 - }
6263 -
6264 - // Get knowledge manager instance to validate role restriction
6265 - $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6266 - $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
6267 - if (!in_array($role_restriction, $valid_roles)) {
6268 - wp_send_json_error('Invalid role restriction');
6269 - exit;
6270 - }
6271 -
6272 - global $wpdb;
6273 -
6274 - if ($data_source === 'pinecone') {
6275 - // Handle Pinecone role restriction (stored separately in WordPress table)
6276 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6277 -
6278 - // Use REPLACE to insert or update the role restriction
6279 - $result = $wpdb->replace(
6280 - $roles_table,
6281 - array(
6282 - 'vector_id' => $entry_id,
6283 - 'role_restriction' => $role_restriction,
6284 - 'updated_at' => current_time('mysql')
6285 - ),
6286 - array('%s', '%s', '%s')
6287 - );
6288 -
6289 - // No cache clearing needed since we removed caching
6290 -
6291 - } else {
6292 - // Handle WordPress database role restriction (existing functionality)
6293 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6294 -
6295 - $result = $wpdb->update(
6296 - $table_name,
6297 - array('role_restriction' => $role_restriction),
6298 - array('id' => absint($entry_id)),
6299 - array('%s'),
6300 - array('%d')
6301 - );
6302 - }
6303 -
6304 - if ($result === false) {
6305 - wp_send_json_error('Database update failed: ' . $wpdb->last_error);
6306 - exit;
6307 - }
6308 -
6309 - wp_send_json_success(array(
6310 - 'message' => 'Role restriction updated successfully',
6311 - 'role_restriction' => $role_restriction,
6312 - 'data_source' => $data_source,
6313 - 'entry_id' => $entry_id
6314 - ));
6315 - exit;
6316 -}
6317 -
6318 -// ========================================
6319 -// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
6320 -// Add these to your MxChat_Knowledge_Manager class
6321 -// ========================================
6322 -
6323 -/**
6324 - * Initialize role-based content hooks
6325 - * Add this call to your __construct() or mxchat_init_hooks() method
6326 - */
6327 -private function mxchat_init_role_hooks() {
6328 - // AJAX handlers for tag-role mappings
6329 - add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
6330 - add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
6331 - add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
6332 - add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
6333 -
6334 - // Hook to automatically update role restrictions when tags are added/removed
6335 - add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
6336 -
6337 - // Hook to apply role restrictions on auto-sync
6338 - add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
6339 -}
6340 -
6341 -/**
6342 - * Add tag-role mapping via AJAX
6343 - */
6344 -public function ajax_add_tag_role_mapping() {
6345 - // Verify nonce and permissions
6346 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6347 -
6348 - if (!current_user_can('manage_options')) {
6349 - wp_send_json_error('Unauthorized access');
6350 - exit;
6351 - }
6352 -
6353 - $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6354 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6355 -
6356 - if (empty($tag_slug)) {
6357 - wp_send_json_error('Tag slug is required');
6358 - exit;
6359 - }
6360 -
6361 - // Validate role restriction
6362 - $valid_roles = array_keys($this->mxchat_get_role_options());
6363 - if (!in_array($role_restriction, $valid_roles)) {
6364 - wp_send_json_error('Invalid role restriction');
6365 - exit;
6366 - }
6367 -
6368 - // Check if tag exists in WordPress
6369 - $term = get_term_by('slug', $tag_slug, 'post_tag');
6370 - if (!$term) {
6371 - wp_send_json_error('Tag does not exist in WordPress');
6372 - exit;
6373 - }
6374 -
6375 - // Get existing mappings
6376 - $mappings = get_option('mxchat_tag_role_mappings', array());
6377 -
6378 - // Check if mapping already exists
6379 - if (isset($mappings[$tag_slug])) {
6380 - wp_send_json_error('Mapping for this tag already exists');
6381 - exit;
6382 - }
6383 -
6384 - // Add new mapping
6385 - $mappings[$tag_slug] = $role_restriction;
6386 - update_option('mxchat_tag_role_mappings', $mappings);
6387 -
6388 - wp_send_json_success(array(
6389 - 'message' => 'Tag-role mapping added successfully',
6390 - 'tag_slug' => $tag_slug,
6391 - 'role_restriction' => $role_restriction
6392 - ));
6393 - exit;
6394 -}
6395 -
6396 -/**
6397 - * Delete tag-role mapping via AJAX
6398 - */
6399 -public function ajax_delete_tag_role_mapping() {
6400 - // Verify nonce and permissions
6401 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6402 -
6403 - if (!current_user_can('manage_options')) {
6404 - wp_send_json_error('Unauthorized access');
6405 - exit;
6406 - }
6407 -
6408 - $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6409 -
6410 - if (empty($tag_slug)) {
6411 - wp_send_json_error('Tag slug is required');
6412 - exit;
6413 - }
6414 -
6415 - // Get existing mappings
6416 - $mappings = get_option('mxchat_tag_role_mappings', array());
6417 -
6418 - // Check if mapping exists
6419 - if (!isset($mappings[$tag_slug])) {
6420 - wp_send_json_error('Mapping does not exist');
6421 - exit;
6422 - }
6423 -
6424 - // Remove mapping
6425 - unset($mappings[$tag_slug]);
6426 - update_option('mxchat_tag_role_mappings', $mappings);
6427 -
6428 - wp_send_json_success(array(
6429 - 'message' => 'Tag-role mapping deleted successfully',
6430 - 'tag_slug' => $tag_slug
6431 - ));
6432 - exit;
6433 -}
6434 -
6435 -/**
6436 - * Get all tag-role mappings via AJAX
6437 - */
6438 -public function ajax_get_tag_role_mappings() {
6439 - // Verify nonce and permissions
6440 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6441 -
6442 - if (!current_user_can('manage_options')) {
6443 - wp_send_json_error('Unauthorized access');
6444 - exit;
6445 - }
6446 -
6447 - // Get mappings
6448 - $mappings = get_option('mxchat_tag_role_mappings', array());
6449 - $role_options = $this->mxchat_get_role_options();
6450 -
6451 - $formatted_mappings = array();
6452 -
6453 - foreach ($mappings as $tag_slug => $role_restriction) {
6454 - // Get tag object
6455 - $term = get_term_by('slug', $tag_slug, 'post_tag');
6456 -
6457 - // Count posts with this tag
6458 - $post_count = 0;
6459 - if ($term) {
6460 - $post_count = $term->count;
6461 - }
6462 -
6463 - $formatted_mappings[] = array(
6464 - 'tag_slug' => $tag_slug,
6465 - 'role_restriction' => $role_restriction,
6466 - 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
6467 - 'post_count' => $post_count
6468 - );
6469 - }
6470 -
6471 - wp_send_json_success(array(
6472 - 'mappings' => $formatted_mappings
6473 - ));
6474 - exit;
6475 -}
6476 -
6477 -/**
6478 - * Bulk update role restrictions for all existing content with mapped tags
6479 - */
6480 -public function ajax_bulk_update_tag_roles() {
6481 - // Verify nonce and permissions
6482 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
6483 -
6484 - if (!current_user_can('manage_options')) {
6485 - wp_send_json_error('Unauthorized access');
6486 - exit;
6487 - }
6488 -
6489 - // Get mappings
6490 - $mappings = get_option('mxchat_tag_role_mappings', array());
6491 -
6492 - if (empty($mappings)) {
6493 - wp_send_json_error('No tag-role mappings found');
6494 - exit;
6495 - }
6496 -
6497 - global $wpdb;
6498 -
6499 - // Check if using Pinecone
6500 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6501 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6502 -
6503 - $updated_count = 0;
6504 - $details = array();
6505 -
6506 - foreach ($mappings as $tag_slug => $role_restriction) {
6507 - // Get all posts with this tag
6508 - $posts = get_posts(array(
6509 - 'tag' => $tag_slug,
6510 - 'post_type' => 'any',
6511 - 'posts_per_page' => -1,
6512 - 'fields' => 'ids',
6513 - 'post_status' => 'publish'
6514 - ));
6515 -
6516 - if (empty($posts)) {
6517 - continue;
6518 - }
6519 -
6520 - $tag_updated = 0;
6521 -
6522 - foreach ($posts as $post_id) {
6523 - $source_url = get_permalink($post_id);
6524 - if (!$source_url) {
6525 - continue;
6526 - }
6527 -
6528 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6529 - // Update Pinecone role restriction
6530 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6531 - $vector_id = md5($source_url);
6532 -
6533 - $result = $wpdb->replace(
6534 - $roles_table,
6535 - array(
6536 - 'vector_id' => $vector_id,
6537 - 'role_restriction' => $role_restriction,
6538 - 'updated_at' => current_time('mysql')
6539 - ),
6540 - array('%s', '%s', '%s')
6541 - );
6542 - } else {
6543 - // Update WordPress DB
6544 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6545 -
6546 - $result = $wpdb->update(
6547 - $table_name,
6548 - array('role_restriction' => $role_restriction),
6549 - array('source_url' => $source_url),
6550 - array('%s'),
6551 - array('%s')
6552 - );
6553 - }
6554 -
6555 - if ($result !== false) {
6556 - $tag_updated++;
6557 - $updated_count++;
6558 - }
6559 - }
6560 -
6561 - if ($tag_updated > 0) {
6562 - $details[] = sprintf(
6563 - 'Tag "%s" (%s): %d posts updated',
6564 - $tag_slug,
6565 - $role_restriction,
6566 - $tag_updated
6567 - );
6568 - }
6569 - }
6570 -
6571 - wp_send_json_success(array(
6572 - 'message' => 'Bulk update completed',
6573 - 'updated_count' => $updated_count,
6574 - 'tags_processed' => count($mappings),
6575 - 'details' => $details
6576 - ));
6577 - exit;
6578 -}
6579 -
6580 -/**
6581 - * Handle tag changes on posts (when tags are added or removed)
6582 - */
6583 -public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
6584 - // Only process post tags
6585 - if ($taxonomy !== 'post_tag') {
6586 - return;
6587 - }
6588 -
6589 - // Get tag-role mappings
6590 - $mappings = get_option('mxchat_tag_role_mappings', array());
6591 -
6592 - if (empty($mappings)) {
6593 - return;
6594 - }
6595 -
6596 - // Get the post's URL
6597 - $source_url = get_permalink($object_id);
6598 - if (!$source_url) {
6599 - return;
6600 - }
6601 -
6602 - // Determine the highest role restriction based on tags
6603 - $highest_role = 'public';
6604 - $role_hierarchy = array(
6605 - 'public' => 0,
6606 - 'logged_in' => 1,
6607 - 'subscriber' => 2,
6608 - 'contributor' => 3,
6609 - 'author' => 4,
6610 - 'editor' => 5,
6611 - 'administrator' => 6
6612 - );
6613 -
6614 - // Get all current tags for the post
6615 - $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
6616 -
6617 - // Find the highest role restriction among the tags
6618 - foreach ($current_tags as $tag_slug) {
6619 - if (isset($mappings[$tag_slug])) {
6620 - $role = $mappings[$tag_slug];
6621 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6622 - $highest_role = $role;
6623 - }
6624 - }
6625 - }
6626 -
6627 - // Update the role restriction in the database
6628 - global $wpdb;
6629 -
6630 - // Check if using Pinecone
6631 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6632 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6633 -
6634 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6635 - // Update Pinecone role restriction
6636 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6637 - $vector_id = md5($source_url);
6638 -
6639 - $wpdb->replace(
6640 - $roles_table,
6641 - array(
6642 - 'vector_id' => $vector_id,
6643 - 'role_restriction' => $highest_role,
6644 - 'updated_at' => current_time('mysql')
6645 - ),
6646 - array('%s', '%s', '%s')
6647 - );
6648 - } else {
6649 - // Update WordPress DB
6650 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6651 -
6652 - $wpdb->update(
6653 - $table_name,
6654 - array('role_restriction' => $highest_role),
6655 - array('source_url' => $source_url),
6656 - array('%s'),
6657 - array('%s')
6658 - );
6659 - }
6660 -}
6661 -
6662 -/**
6663 - * Apply role restriction after content is stored (for auto-sync)
6664 - */
6665 -public function apply_role_restriction_after_storage($post_id, $source_url) {
6666 - // Get tag-role mappings
6667 - $mappings = get_option('mxchat_tag_role_mappings', array());
6668 -
6669 - if (empty($mappings)) {
6670 - return;
6671 - }
6672 -
6673 - // Get all tags for the post
6674 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
6675 -
6676 - if (empty($post_tags)) {
6677 - return;
6678 - }
6679 -
6680 - // Determine the highest role restriction based on tags
6681 - $highest_role = 'public';
6682 - $role_hierarchy = array(
6683 - 'public' => 0,
6684 - 'logged_in' => 1,
6685 - 'subscriber' => 2,
6686 - 'contributor' => 3,
6687 - 'author' => 4,
6688 - 'editor' => 5,
6689 - 'administrator' => 6
6690 - );
6691 -
6692 - foreach ($post_tags as $tag_slug) {
6693 - if (isset($mappings[$tag_slug])) {
6694 - $role = $mappings[$tag_slug];
6695 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6696 - $highest_role = $role;
6697 - }
6698 - }
6699 - }
6700 -
6701 - // If no restricted tags found, return (leave as public)
6702 - if ($highest_role === 'public') {
6703 - return;
6704 - }
6705 -
6706 - // Update the role restriction
6707 - global $wpdb;
6708 -
6709 - // Check if using Pinecone
6710 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6711 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6712 -
6713 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6714 - // Update Pinecone role restriction
6715 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6716 - $vector_id = md5($source_url);
6717 -
6718 - $wpdb->replace(
6719 - $roles_table,
6720 - array(
6721 - 'vector_id' => $vector_id,
6722 - 'role_restriction' => $highest_role,
6723 - 'updated_at' => current_time('mysql')
6724 - ),
6725 - array('%s', '%s', '%s')
6726 - );
6727 - } else {
6728 - // Update WordPress DB
6729 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6730 -
6731 - $wpdb->update(
6732 - $table_name,
6733 - array('role_restriction' => $highest_role),
6734 - array('source_url' => $source_url),
6735 - array('%s'),
6736 - array('%s')
6737 - );
6738 - }
6739 -}
6740 -
6741 -
6742 - // ========================================
6743 - // HELPER METHODS
6744 - // ========================================
6745 -
6746 - /**
6747 - * Check if user has required permissions for content processing
6748 - */
6749 - private function mxchat_check_user_permissions() {
6750 - if (!current_user_can('manage_options')) {
6751 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
6752 - }
6753 - }
6754 -
6755 - /**
6756 - * Validate nonce for security
6757 - */
6758 - private function mxchat_validate_nonce($nonce_name, $nonce_action) {
6759 - if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
6760 - wp_die(esc_html__('Security check failed.', 'mxchat'));
6761 - }
6762 - }
6763 -
6764 - /**
6765 - * Get embedding API credentials
6766 - */
6767 - private function mxchat_get_embedding_credentials() {
6768 - $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
6769 -
6770 - if (strpos($embedding_model, 'text-embedding-') !== false) {
6771 - return array(
6772 - 'type' => 'openai',
6773 - 'api_key' => $this->options['api_key'] ?? ''
6774 - );
6775 - } elseif (strpos($embedding_model, 'voyage-') !== false) {
6776 - return array(
6777 - 'type' => 'voyage',
6778 - 'api_key' => $this->options['voyage_api_key'] ?? ''
6779 - );
6780 - } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
6781 - return array(
6782 - 'type' => 'gemini',
6783 - 'api_key' => $this->options['gemini_api_key'] ?? ''
6784 - );
6785 - }
6786 -
6787 - return array('type' => 'unknown', 'api_key' => '');
6788 - }
6789 -
6790 - /**
6791 - * Log processing errors
6792 - */
6793 - private function mxchat_log_processing_error($operation, $error_message) {
6794 - //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
6795 - }
6796 -
6797 - /**
6798 - * Set admin notice transient
6799 - */
6800 - private function mxchat_set_admin_notice($type, $message) {
6801 - set_transient("mxchat_admin_notice_{$type}", $message, 30);
6802 - }
6803 -
6804 - /**
6805 - * Get Pinecone manager instance for vector operations
6806 - */
6807 - private function mxchat_get_pinecone_manager() {
6808 - return MxChat_Pinecone_Manager::get_instance();
6809 - }
6810 -
6811 -
6812 - // ========================================
6813 -// DATABASE QUEUE TABLE MANAGEMENT
6814 -// ========================================
6815 -
6816 -/**
6817 - * Create queue table on plugin activation
6818 - * Call this from your plugin activation hook
6819 - */
6820 -public function mxchat_create_queue_table() {
6821 - global $wpdb;
6822 -
6823 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6824 - $charset_collate = $wpdb->get_charset_collate();
6825 -
6826 - $sql = "CREATE TABLE IF NOT EXISTS $table_name (
6827 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6828 - queue_id varchar(64) NOT NULL,
6829 - item_type varchar(20) NOT NULL,
6830 - item_data longtext NOT NULL,
6831 - status varchar(20) NOT NULL DEFAULT 'pending',
6832 - bot_id varchar(50) NOT NULL DEFAULT 'default',
6833 - priority int(11) NOT NULL DEFAULT 0,
6834 - attempts int(11) NOT NULL DEFAULT 0,
6835 - max_attempts int(11) NOT NULL DEFAULT 3,
6836 - error_message text DEFAULT NULL,
6837 - created_at datetime NOT NULL,
6838 - started_at datetime DEFAULT NULL,
6839 - completed_at datetime DEFAULT NULL,
6840 - PRIMARY KEY (id),
6841 - KEY queue_id (queue_id),
6842 - KEY status (status),
6843 - KEY item_type (item_type),
6844 - KEY priority (priority)
6845 - ) $charset_collate;";
6846 -
6847 - require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
6848 - dbDelta($sql);
6849 -
6850 - // Also create a meta table for queue metadata
6851 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6852 -
6853 - $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
6854 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6855 - queue_id varchar(64) NOT NULL,
6856 - meta_key varchar(255) NOT NULL,
6857 - meta_value longtext,
6858 - PRIMARY KEY (id),
6859 - KEY queue_id (queue_id),
6860 - KEY meta_key (meta_key)
6861 - ) $charset_collate;";
6862 -
6863 - dbDelta($meta_sql);
6864 -}
6865 -
6866 -/**
6867 - * Add items to the processing queue
6868 - *
6869 - * @param string $queue_id Unique identifier for this queue batch
6870 - * @param string $item_type Type of item (url, pdf_page)
6871 - * @param array $items Array of items to queue
6872 - * @param string $bot_id Bot ID for processing
6873 - * @return int Number of items queued
6874 - */
6875 -private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
6876 - global $wpdb;
6877 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6878 -
6879 - $queued_count = 0;
6880 - $priority = 0;
6881 -
6882 - foreach ($items as $item) {
6883 - $result = $wpdb->insert(
6884 - $table_name,
6885 - array(
6886 - 'queue_id' => $queue_id,
6887 - 'item_type' => $item_type,
6888 - 'item_data' => wp_json_encode($item),
6889 - 'status' => 'pending',
6890 - 'bot_id' => $bot_id,
6891 - 'priority' => $priority,
6892 - 'attempts' => 0,
6893 - 'max_attempts' => 3,
6894 - 'created_at' => current_time('mysql')
6895 - ),
6896 - array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
6897 - );
6898 -
6899 - if ($result) {
6900 - $queued_count++;
6901 - }
6902 -
6903 - $priority++; // Process in order
6904 - }
6905 -
6906 - return $queued_count;
6907 -}
6908 -
6909 -/**
6910 - * Store queue metadata (total counts, source URL, etc.)
6911 - */
6912 -private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
6913 - global $wpdb;
6914 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6915 -
6916 - // Check if meta exists
6917 - $existing = $wpdb->get_var($wpdb->prepare(
6918 - "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6919 - $queue_id,
6920 - $meta_key
6921 - ));
6922 -
6923 - if ($existing) {
6924 - // Update
6925 - $wpdb->update(
6926 - $meta_table,
6927 - array('meta_value' => maybe_serialize($meta_value)),
6928 - array('queue_id' => $queue_id, 'meta_key' => $meta_key),
6929 - array('%s'),
6930 - array('%s', '%s')
6931 - );
6932 - } else {
6933 - // Insert
6934 - $wpdb->insert(
6935 - $meta_table,
6936 - array(
6937 - 'queue_id' => $queue_id,
6938 - 'meta_key' => $meta_key,
6939 - 'meta_value' => maybe_serialize($meta_value)
6940 - ),
6941 - array('%s', '%s', '%s')
6942 - );
6943 - }
6944 -}
6945 -
6946 -/**
6947 - * Get queue metadata
6948 - */
6949 -private function mxchat_get_queue_meta($queue_id, $meta_key) {
6950 - global $wpdb;
6951 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6952 -
6953 - $value = $wpdb->get_var($wpdb->prepare(
6954 - "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6955 - $queue_id,
6956 - $meta_key
6957 - ));
6958 -
6959 - return maybe_unserialize($value);
6960 -}
6961 -
6962 -// ========================================
6963 -// AJAX QUEUE PROCESSING HANDLERS
6964 -// ========================================
6965 -
6966 -/**
6967 - * AJAX: Get next item from queue to process
6968 - */
6969 -public function ajax_mxchat_get_next_queue_item() {
6970 - // Verify nonce and permissions
6971 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
6972 -
6973 - if (!current_user_can('manage_options')) {
6974 - wp_send_json_error('Unauthorized access');
6975 - }
6976 -
6977 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
6978 -
6979 - if (empty($queue_id)) {
6980 - wp_send_json_error('Missing queue ID');
6981 - }
6982 -
6983 - global $wpdb;
6984 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6985 -
6986 - // Get next pending item with retry logic for failed items
6987 - $next_item = $wpdb->get_row($wpdb->prepare(
6988 - "SELECT * FROM $table_name
6989 - WHERE queue_id = %s
6990 - AND status IN ('pending', 'failed')
6991 - AND attempts < max_attempts
6992 - ORDER BY priority ASC, id ASC
6993 - LIMIT 1",
6994 - $queue_id
6995 - ));
6996 -
6997 - if (!$next_item) {
6998 - // No more items - queue complete
6999 - wp_send_json_success(array(
7000 - 'complete' => true,
7001 - 'message' => 'Queue processing complete'
7002 - ));
7003 - }
7004 -
7005 - // Mark item as processing
7006 - $wpdb->update(
7007 - $table_name,
7008 - array(
7009 - 'status' => 'processing',
7010 - 'started_at' => current_time('mysql'),
7011 - 'attempts' => $next_item->attempts + 1
7012 - ),
7013 - array('id' => $next_item->id),
7014 - array('%s', '%s', '%d'),
7015 - array('%d')
7016 - );
7017 -
7018 - wp_send_json_success(array(
7019 - 'complete' => false,
7020 - 'item' => array(
7021 - 'id' => $next_item->id,
7022 - 'type' => $next_item->item_type,
7023 - 'data' => json_decode($next_item->item_data, true),
7024 - 'bot_id' => $next_item->bot_id,
7025 - 'attempt' => $next_item->attempts + 1
7026 - )
7027 - ));
7028 -}
7029 -
7030 -/**
7031 - * AJAX: Process a single queue item
7032 - */
7033 -public function ajax_mxchat_process_queue_item() {
7034 - // Verify nonce and permissions
7035 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7036 -
7037 - if (!current_user_can('manage_options')) {
7038 - wp_send_json_error('Unauthorized access');
7039 - }
7040 -
7041 - $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
7042 - $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
7043 - $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
7044 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
7045 -
7046 - if (empty($item_id) || empty($item_type)) {
7047 - wp_send_json_error('Missing item data');
7048 - }
7049 -
7050 - global $wpdb;
7051 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7052 -
7053 - // Process based on item type
7054 - try {
7055 - set_time_limit(60); // Give processing 60 seconds
7056 -
7057 - $result = false;
7058 - $error_message = '';
7059 -
7060 - // Read item directly from DB to get queue_id and preserve special chars in item_data
7061 - // (POST round-trip through JS mangles characters like apostrophes in URLs)
7062 - $db_item = $wpdb->get_row($wpdb->prepare(
7063 - "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
7064 - $item_id
7065 - ));
7066 - $item_queue_id = $db_item ? $db_item->queue_id : '';
7067 - if ($db_item && !empty($db_item->item_data)) {
7068 - $db_data = json_decode($db_item->item_data, true);
7069 - if (is_array($db_data)) {
7070 - $item_data = $db_data;
7071 - }
7072 - }
7073 -
7074 - switch ($item_type) {
7075 - case 'url':
7076 - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
7077 - break;
7078 -
7079 - case 'pdf_page':
7080 - $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
7081 - break;
7082 -
7083 - default:
7084 - throw new Exception('Unknown item type: ' . $item_type);
7085 - }
7086 -
7087 - if (is_wp_error($result)) {
7088 - $error_code = $result->get_error_code();
7089 - // Content errors (empty page, sanitization) are permanent — retrying won't help
7090 - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
7091 - if (in_array($error_code, $permanent_codes)) {
7092 - // Mark as permanently failed — set attempts = max_attempts so it won't be retried
7093 - $current_item = $wpdb->get_row($wpdb->prepare(
7094 - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
7095 - ));
7096 - $wpdb->update(
7097 - $table_name,
7098 - array(
7099 - 'status' => 'failed',
7100 - 'error_message' => $result->get_error_message(),
7101 - 'attempts' => $current_item ? $current_item->max_attempts : 3
7102 - ),
7103 - array('id' => $item_id),
7104 - array('%s', '%s', '%d'),
7105 - array('%d')
7106 - );
7107 - wp_send_json_error(array(
7108 - 'message' => $result->get_error_message(),
7109 - 'permanent_failure' => true,
7110 - 'item_id' => $item_id
7111 - ));
7112 - return;
7113 - }
7114 - throw new Exception($result->get_error_message());
7115 - }
7116 -
7117 - if ($result === false) {
7118 - throw new Exception('Processing returned false - item may be empty or invalid');
7119 - }
7120 -
7121 - // Mark as completed
7122 - $wpdb->update(
7123 - $table_name,
7124 - array(
7125 - 'status' => 'completed',
7126 - 'completed_at' => current_time('mysql'),
7127 - 'error_message' => null
7128 - ),
7129 - array('id' => $item_id),
7130 - array('%s', '%s', '%s'),
7131 - array('%d')
7132 - );
7133 -
7134 - wp_send_json_success(array(
7135 - 'processed' => true,
7136 - 'item_id' => $item_id,
7137 - 'message' => 'Item processed successfully'
7138 - ));
7139 -
7140 - } catch (Exception $e) {
7141 - $error_message = $e->getMessage();
7142 -
7143 - // Get current attempt count
7144 - $item = $wpdb->get_row($wpdb->prepare(
7145 - "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
7146 - $item_id
7147 - ));
7148 -
7149 - // Check if we've exhausted retries
7150 - if ($item && $item->attempts >= $item->max_attempts) {
7151 - // Permanently failed
7152 - $wpdb->update(
7153 - $table_name,
7154 - array(
7155 - 'status' => 'failed',
7156 - 'error_message' => $error_message
7157 - ),
7158 - array('id' => $item_id),
7159 - array('%s', '%s'),
7160 - array('%d')
7161 - );
7162 -
7163 - wp_send_json_error(array(
7164 - 'message' => 'Item failed after maximum attempts: ' . $error_message,
7165 - 'permanent_failure' => true,
7166 - 'item_id' => $item_id
7167 - ));
7168 - } else {
7169 - // Mark for retry
7170 - $wpdb->update(
7171 - $table_name,
7172 - array(
7173 - 'status' => 'failed',
7174 - 'error_message' => $error_message
7175 - ),
7176 - array('id' => $item_id),
7177 - array('%s', '%s'),
7178 - array('%d')
7179 - );
7180 -
7181 - wp_send_json_error(array(
7182 - 'message' => 'Item processing failed, will retry: ' . $error_message,
7183 - 'can_retry' => true,
7184 - 'item_id' => $item_id,
7185 - 'attempts' => $item ? $item->attempts : 0
7186 - ));
7187 - }
7188 - }
7189 -}
7190 -
7191 -/**
7192 - * Process a URL from the queue
7193 - */
7194 -private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
7195 - $url = isset($item_data['url']) ? $item_data['url'] : '';
7196 -
7197 - if (empty($url)) {
7198 - return new WP_Error('invalid_url', 'URL is empty');
7199 - }
7200 -
7201 - // Get bot-specific API key early (needed for both paths)
7202 - $bot_options = $this->get_bot_options($bot_id);
7203 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7204 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7205 -
7206 - if (strpos($selected_model, 'voyage') === 0) {
7207 - $api_key = $options['voyage_api_key'] ?? '';
7208 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7209 - $api_key = $options['gemini_api_key'] ?? '';
7210 - } else {
7211 - $api_key = $options['api_key'] ?? '';
7212 - }
7213 -
7214 - if (empty($api_key)) {
7215 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7216 - }
7217 -
7218 - // Check if this is a WooCommerce product URL and WooCommerce is active
7219 - $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
7220 - $content_type = $is_product_url ? 'product' : 'url';
7221 -
7222 - // Try to get WooCommerce product data if it's a product URL
7223 - if ($is_product_url && class_exists('WooCommerce')) {
7224 - $product_content = $this->mxchat_extract_woocommerce_product_content($url);
7225 -
7226 - if (!empty($product_content)) {
7227 - // Successfully extracted WooCommerce product data with pricing
7228 - $result = MxChat_Utils::submit_content_to_db(
7229 - $product_content,
7230 - $url,
7231 - $api_key,
7232 - null,
7233 - $bot_id,
7234 - 'product'
7235 - );
7236 - return $result;
7237 - }
7238 - // If WooCommerce extraction failed, fall through to HTML extraction
7239 - }
7240 -
7241 - // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
7242 - $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7243 - $response = wp_remote_get($url, array(
7244 - 'timeout' => $is_likely_pdf ? 120 : 30,
7245 - 'redirection' => 5,
7246 - 'user-agent' => 'MxChat/1.0'
7247 - ));
7248 -
7249 - if (is_wp_error($response)) {
7250 - return $response;
7251 - }
7252 -
7253 - $response_code = wp_remote_retrieve_response_code($response);
7254 - if ($response_code !== 200) {
7255 - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
7256 - }
7257 -
7258 - // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
7259 - if ($this->mxchat_is_pdf_url($url, $response)) {
7260 - return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
7261 - }
7262 -
7263 - $html = wp_remote_retrieve_body($response);
7264 -
7265 - if (empty($html)) {
7266 - return new WP_Error('empty_response', 'Empty response body');
7267 - }
7268 -
7269 - // Extract and sanitize content
7270 - $content = $this->mxchat_extract_main_content($html);
7271 - $sanitized = $this->mxchat_sanitize_content_for_api($content);
7272 -
7273 - if (empty($sanitized)) {
7274 - // Not an error - just no content found (maybe a redirect or empty page)
7275 - return false;
7276 - }
7277 -
7278 - // Submit to database with content_type
7279 - $result = MxChat_Utils::submit_content_to_db(
7280 - $sanitized,
7281 - $url,
7282 - $api_key,
7283 - null,
7284 - $bot_id,
7285 - $content_type
7286 - );
7287 -
7288 - return $result;
7289 -}
7290 -
7291 -/**
7292 - * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
7293 - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
7294 - * and adds pdf_page items to the same queue so they process with full progress tracking.
7295 - */
7296 -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
7297 - set_time_limit(120); // PDFs need extra time for download + parsing
7298 -
7299 - $upload_dir = wp_upload_dir();
7300 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7301 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7302 -
7303 - $response_body = wp_remote_retrieve_body($response);
7304 - if (empty($response_body)) {
7305 - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
7306 - }
7307 -
7308 - if (!wp_mkdir_p(dirname($pdf_path))) {
7309 - return new WP_Error('dir_error', 'Failed to create upload directory');
7310 - }
7311 -
7312 - file_put_contents($pdf_path, $response_body);
7313 -
7314 - if (!file_exists($pdf_path)) {
7315 - return new WP_Error('save_error', 'Failed to save PDF file');
7316 - }
7317 -
7318 - try {
7319 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
7320 -
7321 - if ($total_pages === false || $total_pages < 1) {
7322 - wp_delete_file($pdf_path);
7323 - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
7324 - }
7325 -
7326 - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
7327 - $pages = array();
7328 - for ($i = 1; $i <= $total_pages; $i++) {
7329 - $pages[] = array(
7330 - 'pdf_path' => $pdf_path,
7331 - 'pdf_url' => $pdf_url,
7332 - 'page_number' => $i,
7333 - 'total_pages' => $total_pages
7334 - );
7335 - }
7336 -
7337 - // Add pdf_page items to the SAME queue so the JS picks them up automatically
7338 - if (!empty($queue_id)) {
7339 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
7340 - } else {
7341 - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
7342 - $new_queue_id = 'pdf_' . md5($pdf_url . time());
7343 - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
7344 - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
7345 - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
7346 - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
7347 - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
7348 - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
7349 - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
7350 - }
7351 -
7352 - if ($queued_count === 0) {
7353 - wp_delete_file($pdf_path);
7354 - return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
7355 - }
7356 -
7357 - // Return true so the original URL item is marked complete
7358 - // The new pdf_page items will be processed in subsequent batches
7359 - return true;
7360 -
7361 - } catch (Exception $e) {
7362 - if (file_exists($pdf_path)) {
7363 - wp_delete_file($pdf_path);
7364 - }
7365 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7366 - }
7367 -}
7368 -
7369 -/**
7370 - * Legacy: Process a PDF URL inline during sitemap queue processing.
7371 - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
7372 - */
7373 -private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
7374 - set_time_limit(120); // PDFs need more time — downloading + parsing all pages
7375 -
7376 - $upload_dir = wp_upload_dir();
7377 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7378 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7379 -
7380 - $response_body = wp_remote_retrieve_body($response);
7381 - if (empty($response_body)) {
7382 - return new WP_Error('empty_pdf', 'Empty PDF response');
7383 - }
7384 -
7385 - if (!wp_mkdir_p(dirname($pdf_path))) {
7386 - return new WP_Error('dir_error', 'Failed to create upload directory');
7387 - }
7388 -
7389 - file_put_contents($pdf_path, $response_body);
7390 -
7391 - if (!file_exists($pdf_path)) {
7392 - return new WP_Error('save_error', 'Failed to save PDF file');
7393 - }
7394 -
7395 - try {
7396 - mxchat_load_pdf_parser();
7397 - $parser = new \Smalot\PdfParser\Parser();
7398 - $pdf = $parser->parseFile($pdf_path);
7399 - $pages = $pdf->getPages();
7400 - $total_pages = count($pages);
7401 -
7402 - if ($total_pages < 1) {
7403 - wp_delete_file($pdf_path);
7404 - return new WP_Error('no_pages', 'PDF has no pages');
7405 - }
7406 -
7407 - $processed = 0;
7408 - $skipped_pages = array();
7409 -
7410 - for ($i = 0; $i < $total_pages; $i++) {
7411 - $page_num = $i + 1;
7412 - $text = $pages[$i]->getText();
7413 - if (empty($text)) {
7414 - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
7415 - continue;
7416 - }
7417 -
7418 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
7419 - if (empty($sanitized)) {
7420 - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
7421 - continue;
7422 - }
7423 -
7424 - $metadata = array(
7425 - 'document_type' => 'pdf',
7426 - 'total_pages' => $total_pages,
7427 - 'current_page' => $page_num,
7428 - 'source_url' => $pdf_url,
7429 - );
7430 -
7431 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
7432 - $page_url = esc_url($pdf_url . '#page=' . $page_num);
7433 -
7434 - MxChat_Utils::submit_content_to_db(
7435 - $content_with_metadata,
7436 - $page_url,
7437 - $api_key,
7438 - null,
7439 - $bot_id,
7440 - 'pdf'
7441 - );
7442 -
7443 - $processed++;
7444 - }
7445 -
7446 - // Clean up the temp PDF file
7447 - wp_delete_file($pdf_path);
7448 -
7449 - if (!empty($skipped_pages)) {
7450 - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
7451 - }
7452 -
7453 - return $processed > 0 ? true : false;
7454 -
7455 - } catch (Exception $e) {
7456 - if (file_exists($pdf_path)) {
7457 - wp_delete_file($pdf_path);
7458 - }
7459 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7460 - }
7461 -}
7462 -
7463 -/**
7464 - * Extract WooCommerce product content including pricing
7465 - *
7466 - * @param string $url The product URL
7467 - * @return string|false Product content with pricing, or false if not found
7468 - */
7469 -private function mxchat_extract_woocommerce_product_content($url) {
7470 - // Try to get product ID from URL
7471 - $product_id = url_to_postid($url);
7472 -
7473 - // If url_to_postid fails, try to extract from URL pattern
7474 - if (!$product_id) {
7475 - $product_slug = '';
7476 -
7477 - // Handle pretty permalinks: /product/product-name/
7478 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
7479 - $product_slug = $matches[1];
7480 - }
7481 -
7482 - if (!empty($product_slug)) {
7483 - $product_post = get_page_by_path($product_slug, OBJECT, 'product');
7484 - if ($product_post) {
7485 - $product_id = $product_post->ID;
7486 - }
7487 - }
7488 - }
7489 -
7490 - if (!$product_id) {
7491 - return false;
7492 - }
7493 -
7494 - // Get WooCommerce product object
7495 - $product = wc_get_product($product_id);
7496 -
7497 - if (!$product) {
7498 - return false;
7499 - }
7500 -
7501 - // Build product content with pricing (similar to mxchat_store_product_embedding)
7502 - $title = $product->get_name();
7503 - $description = $product->get_description();
7504 - $short_description = $product->get_short_description();
7505 - $sku = $product->get_sku();
7506 -
7507 - // Get pricing information
7508 - $regular_price = $product->get_regular_price();
7509 - $sale_price = $product->get_sale_price();
7510 - $price = $product->get_price(); // Current active price
7511 -
7512 - // Get currency symbol
7513 - $currency_symbol = get_woocommerce_currency_symbol();
7514 -
7515 - // Format content
7516 - $content = $title . "\n\n";
7517 -
7518 - if (!empty($short_description)) {
7519 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7520 - }
7521 -
7522 - if (!empty($description)) {
7523 - $content .= wp_strip_all_tags($description) . "\n\n";
7524 - }
7525 -
7526 - // Add pricing information
7527 - if (!empty($regular_price)) {
7528 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
7529 - } elseif (!empty($price)) {
7530 - $content .= "Price: " . $currency_symbol . $price . "\n";
7531 - }
7532 -
7533 - if (!empty($sale_price) && $sale_price !== $regular_price) {
7534 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
7535 - }
7536 -
7537 - // Handle variable products - show price range
7538 - if ($product->is_type('variable')) {
7539 - $min_price = $product->get_variation_price('min');
7540 - $max_price = $product->get_variation_price('max');
7541 - if ($min_price !== $max_price) {
7542 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
7543 - }
7544 - }
7545 -
7546 - if (!empty($sku)) {
7547 - $content .= "SKU: " . $sku . "\n";
7548 - }
7549 -
7550 - // Get product categories
7551 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
7552 - if (!empty($categories) && !is_wp_error($categories)) {
7553 - $content .= "Categories: " . implode(', ', $categories) . "\n";
7554 - }
7555 -
7556 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
7557 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7558 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
7559 - foreach ($custom_tabs as $tab) {
7560 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7561 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
7562 -
7563 - if (!empty($tab_title) && !empty($tab_content)) {
7564 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7565 - }
7566 - }
7567 - }
7568 -
7569 - // Also check for reusable/saved tabs applied to this product
7570 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7571 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7572 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7573 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
7574 - foreach ($applied_saved_tabs as $saved_tab_id) {
7575 - if (isset($saved_tabs[$saved_tab_id])) {
7576 - $tab = $saved_tabs[$saved_tab_id];
7577 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7578 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
7579 -
7580 - if (!empty($tab_title) && !empty($tab_content)) {
7581 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7582 - }
7583 - }
7584 - }
7585 - }
7586 - }
7587 -
7588 - return $this->mxchat_sanitize_content_for_api($content);
7589 -}
7590 -
7591 -/**
7592 - * Process a PDF page from the queue
7593 - */
7594 -private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
7595 - $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
7596 - $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
7597 - $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
7598 - $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
7599 -
7600 - if (empty($pdf_path) || !file_exists($pdf_path)) {
7601 - return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
7602 - }
7603 -
7604 - if ($page_number < 1) {
7605 - return new WP_Error('invalid_page', 'Invalid page number');
7606 - }
7607 -
7608 - try {
7609 - mxchat_load_pdf_parser();
7610 - $parser = new \Smalot\PdfParser\Parser();
7611 - $pdf = $parser->parseFile($pdf_path);
7612 - $pages = $pdf->getPages();
7613 -
7614 - if (!isset($pages[$page_number - 1])) {
7615 - return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
7616 - }
7617 -
7618 - $text = $pages[$page_number - 1]->getText();
7619 -
7620 - if (empty($text)) {
7621 - return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
7622 - }
7623 -
7624 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
7625 -
7626 - if (empty($sanitized)) {
7627 - 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');
7628 - }
7629 -
7630 - // Create metadata
7631 - $metadata = array(
7632 - 'document_type' => 'pdf',
7633 - 'total_pages' => $total_pages,
7634 - 'current_page' => $page_number,
7635 - 'source_url' => $pdf_url
7636 - );
7637 -
7638 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
7639 - $page_url = esc_url($pdf_url . "#page=" . $page_number);
7640 -
7641 - // Get bot-specific API key
7642 - $bot_options = $this->get_bot_options($bot_id);
7643 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7644 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7645 -
7646 - if (strpos($selected_model, 'voyage') === 0) {
7647 - $api_key = $options['voyage_api_key'] ?? '';
7648 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7649 - $api_key = $options['gemini_api_key'] ?? '';
7650 - } else {
7651 - $api_key = $options['api_key'] ?? '';
7652 - }
7653 -
7654 - if (empty($api_key)) {
7655 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7656 - }
7657 -
7658 - // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
7659 - $result = MxChat_Utils::submit_content_to_db(
7660 - $content_with_metadata,
7661 - $page_url,
7662 - $api_key,
7663 - null,
7664 - $bot_id,
7665 - 'pdf'
7666 - );
7667 -
7668 - return $result;
7669 -
7670 - } catch (Exception $e) {
7671 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7672 - }
7673 -}
7674 -
7675 -/**
7676 - * AJAX: Get queue processing status
7677 - */
7678 -public function ajax_mxchat_get_queue_status() {
7679 - // Verify nonce and permissions
7680 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7681 -
7682 - if (!current_user_can('manage_options')) {
7683 - wp_send_json_error('Unauthorized access');
7684 - }
7685 -
7686 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7687 -
7688 - if (empty($queue_id)) {
7689 - wp_send_json_error('Missing queue ID');
7690 - }
7691 -
7692 - global $wpdb;
7693 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7694 -
7695 - // Get counts by status
7696 - $counts = $wpdb->get_results($wpdb->prepare(
7697 - "SELECT status, COUNT(*) as count
7698 - FROM $table_name
7699 - WHERE queue_id = %s
7700 - GROUP BY status",
7701 - $queue_id
7702 - ), OBJECT_K);
7703 -
7704 - $total = 0;
7705 - $completed = 0;
7706 - $failed = 0;
7707 - $processing = 0;
7708 - $pending = 0;
7709 -
7710 - foreach ($counts as $status => $data) {
7711 - $count = absint($data->count);
7712 - $total += $count;
7713 -
7714 - switch ($status) {
7715 - case 'completed':
7716 - $completed = $count;
7717 - break;
7718 - case 'failed':
7719 - $failed = $count;
7720 - break;
7721 - case 'processing':
7722 - $processing = $count;
7723 - break;
7724 - case 'pending':
7725 - $pending = $count;
7726 - break;
7727 - }
7728 - }
7729 -
7730 - // Calculate percentage
7731 - $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
7732 -
7733 - // Get failed items details (include all failed items, not just those that exhausted retries)
7734 - $failed_items = array();
7735 - if ($failed > 0) {
7736 - $failed_items = $wpdb->get_results($wpdb->prepare(
7737 - "SELECT item_type, item_data, error_message, attempts
7738 - FROM $table_name
7739 - WHERE queue_id = %s
7740 - AND status = 'failed'
7741 - ORDER BY id DESC
7742 - LIMIT 50",
7743 - $queue_id
7744 - ));
7745 - }
7746 -
7747 - // Get queue metadata
7748 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
7749 - $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
7750 -
7751 - // Determine if queue is complete
7752 - $is_complete = ($pending === 0 && $processing === 0);
7753 -
7754 - wp_send_json_success(array(
7755 - 'queue_id' => $queue_id,
7756 - 'queue_type' => $queue_type,
7757 - 'source_url' => $source_url,
7758 - 'total' => $total,
7759 - 'completed' => $completed,
7760 - 'failed' => $failed,
7761 - 'processing' => $processing,
7762 - 'pending' => $pending,
7763 - 'percentage' => $percentage,
7764 - 'is_complete' => $is_complete,
7765 - 'failed_items' => $failed_items,
7766 - 'status' => $is_complete ? 'complete' : 'processing'
7767 - ));
7768 -}
7769 -
7770 -/**
7771 - * AJAX: Clear completed queue
7772 - */
7773 -public function ajax_mxchat_clear_queue() {
7774 - // Verify nonce and permissions
7775 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7776 -
7777 - if (!current_user_can('manage_options')) {
7778 - wp_send_json_error('Unauthorized access');
7779 - }
7780 -
7781 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7782 -
7783 - if (empty($queue_id)) {
7784 - wp_send_json_error('Missing queue ID');
7785 - }
7786 -
7787 - global $wpdb;
7788 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7789 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7790 -
7791 - // Delete queue items
7792 - $wpdb->delete(
7793 - $table_name,
7794 - array('queue_id' => $queue_id),
7795 - array('%s')
7796 - );
7797 -
7798 - // Delete queue metadata
7799 - $wpdb->delete(
7800 - $meta_table,
7801 - array('queue_id' => $queue_id),
7802 - array('%s')
7803 - );
7804 -
7805 - wp_send_json_success(array(
7806 - 'message' => 'Queue cleared successfully'
7807 - ));
7808 -}
7809 -
7810 -/**
7811 - * AJAX: Retry failed items in queue
7812 - */
7813 -public function ajax_mxchat_retry_failed() {
7814 - // Verify nonce and permissions
7815 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7816 -
7817 - if (!current_user_can('manage_options')) {
7818 - wp_send_json_error('Unauthorized access');
7819 - }
7820 -
7821 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7822 -
7823 - if (empty($queue_id)) {
7824 - wp_send_json_error('Missing queue ID');
7825 - }
7826 -
7827 - global $wpdb;
7828 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7829 -
7830 - // Reset failed items to pending and reset attempt count
7831 - $updated = $wpdb->update(
7832 - $table_name,
7833 - array(
7834 - 'status' => 'pending',
7835 - 'attempts' => 0,
7836 - 'error_message' => null
7837 - ),
7838 - array(
7839 - 'queue_id' => $queue_id,
7840 - 'status' => 'failed'
7841 - ),
7842 - array('%s', '%d', '%s'),
7843 - array('%s', '%s')
7844 - );
7845 -
7846 - wp_send_json_success(array(
7847 - 'message' => 'Reset ' . $updated . ' failed items for retry',
7848 - 'reset_count' => $updated
7849 - ));
7850 -}
7851 -
7852 -
7853 -public function ajax_mxchat_mark_queue_complete() {
7854 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7855 -
7856 - if (!current_user_can('manage_options')) {
7857 - wp_send_json_error('Unauthorized access');
7858 - }
7859 -
7860 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7861 -
7862 - if (empty($queue_id)) {
7863 - wp_send_json_error('Missing queue ID');
7864 - }
7865 -
7866 - // Clear active queue transients
7867 - if (strpos($queue_id, 'sitemap_') === 0) {
7868 - delete_transient('mxchat_active_queue_sitemap');
7869 - } else if (strpos($queue_id, 'pdf_') === 0) {
7870 - delete_transient('mxchat_active_queue_pdf');
7871 - }
7872 -
7873 - wp_send_json_success(array('message' => 'Queue marked as complete'));
7874 -}
7875 -
7876 -
7877 - // ========================================
7878 - // STATIC ACCESS METHODS
7879 - // ========================================
7880 -
7881 - /**
7882 - * Get singleton instance
7883 - */
7884 - public static function get_instance() {
7885 - static $instance = null;
7886 - if ($instance === null) {
7887 - $instance = new self();
7888 - }
7889 - return $instance;
7890 - }
7891 -}
7892 -
7893 -// 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 + // Post IDs whose vectors were already deleted by mxchat_handle_status_transition this
17 + // request, so the transient-based branch in mxchat_handle_post_update can skip the
18 + // redundant (idempotent but network-visible) second deletion.
19 + private $transition_deleted_posts = array();
20 +
21 + // Post IDs already INDEXED by mxchat_handle_status_transition's arrival edge this
22 + // request. Normal editor publishes fire transition_post_status first, then
23 + // post_updated — without this guard every editor publish would embed twice.
24 + private $transition_indexed_posts = array();
25 +
26 + // Post IDs core has announced an in-flight UPDATE for. pre_post_update fires only
27 + // inside wp_insert_post's update branch and always before wp_transition_post_status,
28 + // so this is an exact "a post_updated is coming later this request" signal — which is
29 + // what makes it safe to arm transition_indexed_posts (plan a664f3).
30 + private $pending_post_update = array();
31 +
32 + /**
33 + * Constructor - Register hooks for content processing
34 + */
35 +public function __construct() {
36 + $this->options = get_option('mxchat_options', array());
37 + $this->mxchat_init_hooks();
38 +
39 + $this->mxchat_init_role_hooks();
40 +}
41 +
42 +/**
43 + * Initialize WordPress hooks for content processing
44 + *
45 + */
46 +private function mxchat_init_hooks() {
47 + // Admin post handlers for form submissions
48 + add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
49 + add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
50 + add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
51 + add_action('admin_post_mxchat_submit_document_file', array($this, 'mxchat_handle_document_file_submission'));
52 + add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission'));
53 + add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
54 +
55 + // AJAX handlers for real-time processing and status updates
56 + add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
57 + add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
58 + add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
59 + add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
60 + add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
61 + add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
62 + add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
63 + add_action('wp_ajax_mxchat_delete_chunks_by_url', array($this, 'ajax_mxchat_delete_chunks_by_url'));
64 + add_action('wp_ajax_mxchat_delete_wordpress_prompt', array($this, 'ajax_mxchat_delete_wordpress_prompt'));
65 + add_action('wp_ajax_mxchat_bulk_delete_knowledge', array($this, 'ajax_mxchat_bulk_delete_knowledge'));
66 + add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
67 +
68 + // Queue-based processing AJAX handlers
69 + add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
70 + add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
71 + add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
72 + add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
73 + add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
74 + add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
75 + add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
76 + add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
77 + add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
78 + add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
79 + add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
80 + add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
81 +
82 + // WordPress post management hooks
83 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
84 + add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
85 + add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
86 + add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
87 + // Authoritative unpublish detection: core hands this hook the REAL previous status, so
88 + // removal no longer depends on the mxchat_prev_status_* transients (evictable by persistent
89 + // object caches, never written by paths that bypass wp_update_post, e.g. plugins flipping
90 + // post_status directly and calling wp_transition_post_status themselves).
91 + add_action('transition_post_status', array($this, 'mxchat_handle_status_transition'), 10, 3);
92 +
93 + // Media-library attachments (plan 01010a). Attachments never reach the post
94 + // hooks above in a usable state — post_updated fires but post_status is
95 + // 'inherit', not 'publish', so the indexing branch always returns early — and
96 + // wp_delete_attachment() fires ONLY 'delete_attachment' (wp_delete_post()
97 + // delegates to it before its own before_delete_post), so the post-delete
98 + // handler never sees one. Both paths therefore need their own hook, and both
99 + // are gated on the SAME 'mxchat_auto_sync_attachment' toggle the Advanced
100 + // Custom Post Sync Settings list already renders for Media.
101 + add_action('add_attachment', array($this, 'mxchat_handle_attachment_change'));
102 + add_action('edit_attachment', array($this, 'mxchat_handle_attachment_change'));
103 + add_action('delete_attachment', array($this, 'mxchat_handle_attachment_delete'));
104 +
105 + // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
106 + // Priority 20 to run after ACF's own save (which runs at priority 10)
107 + add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
108 +
109 + // One-time cleanup for vectors orphaned by unpublishes that predate the
110 + // transition_post_status handler (plan 816fb1): wp mxchat prune-unpublished
111 + if (defined('WP_CLI') && WP_CLI) {
112 + WP_CLI::add_command('mxchat prune-unpublished', array($this, 'cli_prune_unpublished'));
113 + // In-place repair for RTL KB rows imported in visual order before the
114 + // 32bf9e normalizer existed: wp mxchat rtl-repair (plan d1e6f7)
115 + WP_CLI::add_command('mxchat rtl-repair', array($this, 'cli_rtl_repair'));
116 + // One command for the entries that five import fixes left behind:
117 + // wp mxchat kb-reimport (plan 89739b)
118 + WP_CLI::add_command('mxchat kb-reimport', array($this, 'cli_kb_reimport'));
119 + }
120 + add_action('wp_ajax_mxchat_kb_reimport', array($this, 'ajax_mxchat_kb_reimport'));
121 +
122 + add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
123 +
124 + // WooCommerce product hooks (if WooCommerce is active)
125 + if (class_exists('WooCommerce')) {
126 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
127 + add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
128 + add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
129 + add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
130 + }
131 +}
132 +
133 + /**
134 + * Get current options (refreshed)
135 + */
136 + private function mxchat_get_options() {
137 + if (empty($this->options)) {
138 + $this->options = get_option('mxchat_options', array());
139 + }
140 + return $this->options;
141 + }
142 +
143 +
144 + // ========================================
145 + // MAIN CONTENT SUBMISSION HANDLERS
146 + // ========================================
147 +
148 +public function mxchat_handle_content_submission() {
149 + // Check if the form was submitted and the user has permission.
150 + if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
151 + return;
152 + }
153 +
154 + // Verify the nonce.
155 + $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
156 + if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
157 + wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
158 + }
159 +
160 + // Sanitize the inputs.
161 + // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
162 + $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
163 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
164 +
165 + // Get bot_id from form submission
166 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
167 +
168 + // Get bot-specific options and API key
169 + $bot_options = $this->get_bot_options($bot_id);
170 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
171 +
172 + // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd).
173 + $preflight = MxChat_Utils::embedding_preflight($options);
174 + if (!$preflight['ok']) {
175 + set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
176 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
177 + exit;
178 + }
179 + $api_key = $preflight['api_key'];
180 +
181 + // Use centralized utility function with bot_id
182 + $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
183 +
184 + if (is_wp_error($result)) {
185 + set_transient('mxchat_admin_notice_error',
186 + esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
187 + 30
188 + );
189 + } else {
190 + set_transient('mxchat_admin_notice_success',
191 + esc_html__('Content successfully submitted!', 'mxchat'),
192 + 30
193 + );
194 + }
195 +
196 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
197 + exit;
198 +}
199 +
200 +/**
201 + * Handle the "YouTube" KB import source (admin-post form submission).
202 + *
203 + * Per-video description mode:
204 + * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
205 + * If no usable transcript, index the metadata anyway, tell the admin,
206 + * and bounce back with the manual box pre-filled (never fail silently).
207 + * - manual: the admin's own description is what gets indexed; metadata rides along.
208 + *
209 + * The row is stored with content_type 'youtube' and source_url = the canonical
210 + * watch URL, so re-importing the same video UPDATES the entry (source_url
211 + * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
212 + * "augment a metadata-only entry" path.
213 + */
214 +public function mxchat_handle_youtube_submission() {
215 + if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
216 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
217 + }
218 +
219 + check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
220 +
221 + $redirect_url = admin_url('admin.php?page=mxchat-prompts');
222 +
223 + $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
224 + $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
225 +
226 + if (empty($video_id)) {
227 + set_transient('mxchat_admin_notice_error',
228 + esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
229 + 30
230 + );
231 + wp_safe_redirect(esc_url($redirect_url));
232 + exit;
233 + }
234 +
235 + $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
236 +
237 + $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
238 + $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
239 +
240 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
241 +
242 + // Resolve the embedding decision exactly like the sibling handlers —
243 + // custom-provider-aware (plan cbd5fd).
244 + $bot_options = $this->get_bot_options($bot_id);
245 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
246 +
247 + $preflight = MxChat_Utils::embedding_preflight($options);
248 + if (!$preflight['ok']) {
249 + set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
250 + wp_safe_redirect(esc_url($redirect_url));
251 + exit;
252 + }
253 + $api_key = $preflight['api_key'];
254 +
255 + // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
256 + // manual mode it enriches the indexed text with the real title/channel.
257 + $meta = $this->mxchat_fetch_youtube_oembed($video_id);
258 + $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
259 + $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
260 +
261 + $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
262 + if ($video_channel !== '') {
263 + $header_lines .= 'Channel: ' . $video_channel . "\n";
264 + }
265 + $header_lines .= 'URL: ' . $canonical_url . "\n\n";
266 +
267 + $transcript_missing = false;
268 +
269 + if ($description_mode === 'manual') {
270 + if ($manual_description === '') {
271 + set_transient('mxchat_admin_notice_error',
272 + esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
273 + 30
274 + );
275 + wp_safe_redirect(esc_url($redirect_url));
276 + exit;
277 + }
278 + $indexed_text = $header_lines . $manual_description;
279 + } else {
280 + $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
281 +
282 + if (strlen($transcript) >= 200) {
283 + $indexed_text = $header_lines . $transcript;
284 + } else {
285 + // Graceful fallback: captions disabled / blocked / no speech. Auto
286 + // reliably gets metadata; it does NOT guarantee a transcript.
287 + $transcript_missing = true;
288 +
289 + if ($video_title === '' && $video_channel === '') {
290 + // Both halves failed — nothing meaningful to index.
291 + set_transient('mxchat_admin_notice_error',
292 + esc_html__('Could not retrieve any information for that video (no metadata and no captions). Please check the URL, or use the manual description option.', 'mxchat'),
293 + 30
294 + );
295 + wp_safe_redirect(esc_url($redirect_url));
296 + exit;
297 + }
298 +
299 + $indexed_text = $header_lines . sprintf(
300 + /* translators: 1: video title, 2: channel name */
301 + __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
302 + $video_title !== '' ? $video_title : $canonical_url,
303 + $video_channel !== '' ? $video_channel : 'YouTube'
304 + );
305 + }
306 + }
307 +
308 + $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
309 +
310 + if (is_wp_error($result)) {
311 + set_transient('mxchat_admin_notice_error',
312 + esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
313 + 30
314 + );
315 + wp_safe_redirect(esc_url($redirect_url));
316 + exit;
317 + }
318 +
319 + if ($transcript_missing) {
320 + set_transient('mxchat_admin_notice_success',
321 + esc_html__('Video indexed from its title and channel — no captions were available for a transcript. The form below is pre-filled: write your own description and import again to improve matching (it updates the same entry).', 'mxchat'),
322 + 30
323 + );
324 + // Bounce back with prefill args so the page reopens the YouTube form in
325 + // manual mode with the URL + fetched title ready to augment.
326 + $redirect_url = add_query_arg(array(
327 + 'mxchat_yt_prefill' => '1',
328 + 'yt_url' => rawurlencode($canonical_url),
329 + 'yt_title' => rawurlencode($video_title),
330 + ), $redirect_url);
331 + } else {
332 + set_transient('mxchat_admin_notice_success',
333 + esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
334 + 30
335 + );
336 + }
337 +
338 + wp_safe_redirect(esc_url_raw($redirect_url));
339 + exit;
340 +}
341 +
342 +/**
343 + * Fetch YouTube oEmbed metadata for a video (no API key required).
344 + * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
345 + */
346 +private function mxchat_fetch_youtube_oembed($video_id) {
347 + $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
348 + $response = wp_remote_get($oembed_url, array('timeout' => 15));
349 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
350 + return array();
351 + }
352 + $data = json_decode(wp_remote_retrieve_body($response), true);
353 + return is_array($data) ? $data : array();
354 +}
355 +
356 +/**
357 + * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
358 + * YouTube's unofficial timedtext route (the caption track list embedded in the
359 + * watch page), which YouTube has broken before and will break again. Every
360 + * failure mode returns '' so a break degrades to the metadata-only import path
361 + * instead of erroring the whole submission. Do not let anything in here throw.
362 + */
363 +private function mxchat_fetch_youtube_transcript($video_id) {
364 + $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
365 +
366 + // First try the honest ingest UA; some responses omit the player config for
367 + // bot UAs, so retry once with a browser UA before giving up.
368 + $user_agents = array(
369 + mxchat_ingest_user_agent(),
370 + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
371 + );
372 +
373 + $tracks = array();
374 + foreach ($user_agents as $ua) {
375 + $response = wp_remote_get($watch_url, array(
376 + 'timeout' => 20,
377 + 'user-agent' => $ua,
378 + ));
379 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
380 + continue;
381 + }
382 + $body = wp_remote_retrieve_body($response);
383 + if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
384 + continue;
385 + }
386 + $decoded = json_decode($m[1], true);
387 + if (is_array($decoded) && !empty($decoded)) {
388 + $tracks = $decoded;
389 + break;
390 + }
391 + }
392 +
393 + if (empty($tracks)) {
394 + return '';
395 + }
396 +
397 + // Prefer an English track, else take the first offered.
398 + $chosen = null;
399 + foreach ($tracks as $track) {
400 + if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
401 + $chosen = $track;
402 + break;
403 + }
404 + }
405 + if ($chosen === null) {
406 + $chosen = $tracks[0];
407 + }
408 + if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
409 + return '';
410 + }
411 +
412 + $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
413 + if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
414 + return '';
415 + }
416 + $xml = wp_remote_retrieve_body($timedtext);
417 + if (!is_string($xml) || strpos($xml, '<text') === false) {
418 + return '';
419 + }
420 +
421 + // <text start=".." dur="..">caption</text> — strip tags, decode the
422 + // double-encoded entities timedtext ships, collapse whitespace.
423 + $text = preg_replace('/<[^>]+>/', ' ', $xml);
424 + $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
425 + $text = trim(preg_replace('/\s+/u', ' ', $text));
426 +
427 + return $text;
428 +}
429 +
430 +public function mxchat_is_pdf_url($url, $response) {
431 + $content_type = wp_remote_retrieve_header($response, 'content-type');
432 + $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
433 +
434 + // Check Content-Disposition header for .pdf filename (Google Drive sends this)
435 + $disposition = wp_remote_retrieve_header($response, 'content-disposition');
436 + $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
437 +
438 + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
439 +}
440 +
441 +
442 +public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
443 + if (!current_user_can('manage_options')) {
444 + return false;
445 + }
446 +
447 + $pdf_url = esc_url_raw($pdf_url);
448 + $upload_dir = wp_upload_dir();
449 +
450 + if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
451 + return false;
452 + }
453 +
454 + $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
455 + $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
456 +
457 + $response_body = wp_remote_retrieve_body($response);
458 + if (empty($response_body)) {
459 + return false;
460 + }
461 +
462 + if (!wp_mkdir_p(dirname($pdf_path))) {
463 + return false;
464 + }
465 +
466 + try {
467 + file_put_contents($pdf_path, $response_body);
468 +
469 + if (!file_exists($pdf_path)) {
470 + throw new Exception(__('Failed to save PDF file', 'mxchat'));
471 + }
472 +
473 + $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
474 +
475 + if ($total_pages === false || $total_pages < 1) {
476 + throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
477 + }
478 +
479 + // Create unique queue ID
480 + $queue_id = 'pdf_' . md5($pdf_url . time());
481 +
482 + // Create array of pages to process
483 + $pages = array();
484 + for ($i = 1; $i <= $total_pages; $i++) {
485 + $pages[] = array(
486 + 'pdf_path' => $pdf_path,
487 + 'pdf_url' => $pdf_url,
488 + 'page_number' => $i,
489 + 'total_pages' => $total_pages
490 + );
491 + }
492 +
493 + // Add pages to queue
494 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
495 +
496 + if ($queued_count === 0) {
497 + wp_delete_file($pdf_path);
498 + throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
499 + }
500 +
501 + // Store queue metadata
502 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
503 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
504 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
505 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
506 + $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
507 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
508 +
509 + // Store queue ID in transient for status tracking
510 + set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
511 + set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
512 +
513 + return 'queued';
514 +
515 + } catch (Exception $e) {
516 + if (file_exists($pdf_path)) {
517 + wp_delete_file($pdf_path);
518 + }
519 + return $e->getMessage();
520 + }
521 +}
522 +
523 +/**
524 + * Handle direct PDF file upload from the knowledge base page
525 + */
526 +public function mxchat_handle_pdf_file_submission() {
527 + if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
528 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
529 + }
530 +
531 + check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
532 +
533 + $redirect_url = admin_url('admin.php?page=mxchat-prompts');
534 +
535 + // Validate file upload
536 + if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
537 + $error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
538 + $error_messages = array(
539 + UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
540 + UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
541 + UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
542 + UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
543 + UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
544 + UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
545 + );
546 + $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
547 + set_transient('mxchat_admin_notice_error', $error_msg, 30);
548 + wp_safe_redirect(esc_url($redirect_url));
549 + exit;
550 + }
551 +
552 + $file = $_FILES['pdf_file'];
553 +
554 + // Validate MIME type
555 + $finfo = finfo_open(FILEINFO_MIME_TYPE);
556 + $mime_type = finfo_file($finfo, $file['tmp_name']);
557 + finfo_close($finfo);
558 +
559 + if ($mime_type !== 'application/pdf') {
560 + set_transient('mxchat_admin_notice_error',
561 + esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
562 + 30
563 + );
564 + wp_safe_redirect(esc_url($redirect_url));
565 + exit;
566 + }
567 +
568 + // Validate extension
569 + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
570 + if ($ext !== 'pdf') {
571 + set_transient('mxchat_admin_notice_error',
572 + esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
573 + 30
574 + );
575 + wp_safe_redirect(esc_url($redirect_url));
576 + exit;
577 + }
578 +
579 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
580 + $original_filename = sanitize_file_name($file['name']);
581 +
582 + $upload_dir = wp_upload_dir();
583 + if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
584 + set_transient('mxchat_admin_notice_error',
585 + esc_html__('WordPress upload directory is not writable.', 'mxchat'),
586 + 30
587 + );
588 + wp_safe_redirect(esc_url($redirect_url));
589 + exit;
590 + }
591 +
592 + $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
593 + $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
594 +
595 + if (!wp_mkdir_p(dirname($pdf_path))) {
596 + set_transient('mxchat_admin_notice_error',
597 + esc_html__('Failed to create upload directory.', 'mxchat'),
598 + 30
599 + );
600 + wp_safe_redirect(esc_url($redirect_url));
601 + exit;
602 + }
603 +
604 + // Move uploaded file
605 + if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
606 + set_transient('mxchat_admin_notice_error',
607 + esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
608 + 30
609 + );
610 + wp_safe_redirect(esc_url($redirect_url));
611 + exit;
612 + }
613 +
614 + try {
615 + $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
616 +
617 + if ($total_pages === false || $total_pages < 1) {
618 + throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
619 + }
620 +
621 + // Use original filename as the source identifier
622 + $source_label = 'upload://' . $original_filename;
623 +
624 + // Optional page the PDF is cited as (plans 025ea2 / dcc19d). Kept
625 + // beside the file's upload:// identity, which every page carries, so a
626 + // re-upload still replaces the pages; leaving the field empty on a
627 + // re-upload clears an earlier page.
628 + MxChat_Utils::set_upload_source_url(
629 + $source_label,
630 + $bot_id,
631 + isset($_POST['source_page_url']) ? wp_unslash($_POST['source_page_url']) : ''
632 + );
633 +
634 + $queue_id = 'pdf_' . md5($source_label . time());
635 +
636 + $pages = array();
637 + for ($i = 1; $i <= $total_pages; $i++) {
638 + $pages[] = array(
639 + 'pdf_path' => $pdf_path,
640 + 'pdf_url' => $source_label,
641 + 'page_number' => $i,
642 + 'total_pages' => $total_pages,
643 + );
644 + }
645 +
646 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
647 +
648 + if ($queued_count === 0) {
649 + wp_delete_file($pdf_path);
650 + throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
651 + }
652 +
653 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
654 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
655 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
656 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
657 + $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
658 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
659 +
660 + set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
661 + set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
662 +
663 + set_transient('mxchat_admin_notice_success',
664 + sprintf(
665 + esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
666 + esc_html($original_filename),
667 + $total_pages
668 + ),
669 + 30
670 + );
671 +
672 + } catch (Exception $e) {
673 + if (file_exists($pdf_path)) {
674 + wp_delete_file($pdf_path);
675 + }
676 + set_transient('mxchat_admin_notice_error',
677 + esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
678 + 30
679 + );
680 + }
681 +
682 + wp_safe_redirect(esc_url($redirect_url));
683 + exit;
684 +}
685 +
686 +/**
687 + * Handle direct document upload (.docx / .txt / .md) from the knowledge base
688 + * page (plan 0485e5). Unlike PDF Upload there is no per-page queue: the text
689 + * extracts in one pass and routes through submit_content_to_db, whose chunker
690 + * takes over for long content. The uploaded file is read from the PHP temp
691 + * file and never persisted — only its extracted text enters the KB.
692 + *
693 + * Source identity matches PDF Upload's scheme: upload://<filename>, stable
694 + * across re-uploads so a re-import REPLACES (delete_chunks_for_url + upsert
695 + * per identity) instead of duplicating.
696 + */
697 +public function mxchat_handle_document_file_submission() {
698 + if (!isset($_POST['submit_document_file']) || !current_user_can('manage_options')) {
699 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
700 + }
701 +
702 + check_admin_referer('mxchat_submit_document_file_action', 'mxchat_submit_document_file_nonce');
703 +
704 + $redirect_url = admin_url('admin.php?page=mxchat-prompts');
705 +
706 + if (empty($_FILES['document_file']) || $_FILES['document_file']['error'] !== UPLOAD_ERR_OK) {
707 + $error_code = isset($_FILES['document_file']['error']) ? $_FILES['document_file']['error'] : UPLOAD_ERR_NO_FILE;
708 + $error_messages = array(
709 + UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
710 + UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
711 + UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
712 + UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a document.', 'mxchat'),
713 + UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
714 + UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
715 + );
716 + $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
717 + set_transient('mxchat_admin_notice_error', $error_msg, 30);
718 + wp_safe_redirect(esc_url($redirect_url));
719 + exit;
720 + }
721 +
722 + $file = $_FILES['document_file'];
723 + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
724 +
725 + $finfo = finfo_open(FILEINFO_MIME_TYPE);
726 + $mime_type = finfo_file($finfo, $file['tmp_name']);
727 + finfo_close($finfo);
728 +
729 + // Per-extension MIME expectations. finfo commonly reports .docx as
730 + // application/zip (it IS a Zip container) and .md as plain text.
731 + $mime_ok = false;
732 + if ($ext === 'docx') {
733 + $mime_ok = in_array($mime_type, array(
734 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
735 + 'application/zip',
736 + ), true);
737 + } elseif ($ext === 'txt' || $ext === 'md') {
738 + $mime_ok = (strpos((string) $mime_type, 'text/') === 0);
739 + }
740 +
741 + if (!$mime_ok) {
742 + set_transient('mxchat_admin_notice_error',
743 + esc_html__('Invalid or unreadable document. Accepted types: .docx, .txt, .md.', 'mxchat'),
744 + 30
745 + );
746 + wp_safe_redirect(esc_url($redirect_url));
747 + exit;
748 + }
749 +
750 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
751 + $original_filename = sanitize_file_name($file['name']);
752 +
753 + // ---- Extract text (ONE extractor for .docx — the word handler's) ----
754 + if ($ext === 'docx') {
755 + $text = MXChat_Word_Handler::extract_docx_text($file['tmp_name']);
756 + if ($text === false) {
757 + set_transient('mxchat_admin_notice_error',
758 + esc_html__('The .docx file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
759 + 30
760 + );
761 + wp_safe_redirect(esc_url($redirect_url));
762 + exit;
763 + }
764 + } else {
765 + // .txt / .md read as-is. Markdown keeps its syntax on purpose —
766 + // headings are useful retrieval signal.
767 + $text = (string) file_get_contents($file['tmp_name']);
768 + $text = wp_check_invalid_utf8($text);
769 + $text = trim($text);
770 + }
771 +
772 + if ($text === '') {
773 + set_transient('mxchat_admin_notice_error',
774 + esc_html__('The uploaded document contains no readable text.', 'mxchat'),
775 + 30
776 + );
777 + wp_safe_redirect(esc_url($redirect_url));
778 + exit;
779 + }
780 +
781 + // Size cap — same pdf_max_pages setting the PDF/toolbar paths use, but
782 + // estimated by CHARACTERS (~2500/page): the .docx cleaner collapses all
783 + // newlines to spaces, so a paragraph count reads 1 for any Word file.
784 + // Processing is synchronous — an unbounded document risks a timeout.
785 + $options = get_option('mxchat_options', array());
786 + $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
787 + $estimated_pages = (int) ceil(strlen($text) / 2500);
788 + if ($estimated_pages > $max_pages) {
789 + set_transient('mxchat_admin_notice_error',
790 + sprintf(
791 + esc_html__('The document is too large (about %1$d pages; the limit is %2$d). Split it into smaller files, or raise the PDF max pages setting.', 'mxchat'),
792 + $estimated_pages,
793 + $max_pages
794 + ),
795 + 30
796 + );
797 + wp_safe_redirect(esc_url($redirect_url));
798 + exit;
799 + }
800 +
801 + // Embedding API key — bot-aware, same shape as the direct-content handler.
802 + $api_key = '';
803 + if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
804 + $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
805 + $api_key = $bot_options['api_key'] ?? '';
806 + }
807 + if (empty($api_key)) {
808 + $api_key = $options['api_key'] ?? '';
809 + }
810 +
811 + // Stable identity — PDF Upload's scheme. A re-upload of the same filename
812 + // replaces: clear old chunks first (covers a doc shrinking below the chunk
813 + // threshold, where the single-vector path would not clean them), then
814 + // submit — the chunked path re-deletes harmlessly.
815 + $source_label = 'upload://' . $original_filename;
816 + MxChat_Utils::delete_chunks_for_url($source_label, $bot_id);
817 + $result = MxChat_Utils::submit_content_to_db($text, $source_label, $api_key, null, $bot_id, 'document');
818 +
819 + // Optional page the document is cited as (plan 025ea2). Stored beside the
820 + // entry, not as its identity; an empty field on a re-upload clears it.
821 + if (!is_wp_error($result)) {
822 + MxChat_Utils::set_upload_source_url(
823 + $source_label,
824 + $bot_id,
825 + isset($_POST['source_page_url']) ? wp_unslash($_POST['source_page_url']) : ''
826 + );
827 + }
828 +
829 + if (is_wp_error($result)) {
830 + set_transient('mxchat_admin_notice_error',
831 + esc_html__('Failed to import the document: ', 'mxchat') . esc_html($result->get_error_message()),
832 + 30
833 + );
834 + } else {
835 + set_transient('mxchat_admin_notice_success',
836 + sprintf(
837 + esc_html__('Document "%s" imported into the knowledge base.', 'mxchat'),
838 + esc_html($original_filename)
839 + ),
840 + 30
841 + );
842 + }
843 +
844 + wp_safe_redirect(esc_url($redirect_url));
845 + exit;
846 +}
847 +
848 +/**
849 + * Validate PDF and count pages with multiple parser attempts
850 + */
851 +private function mxchat_validate_and_count_pdf_pages($pdf_path) {
852 + // Method 1: Try with Smalot PDF Parser (your current method)
853 + try {
854 + mxchat_load_pdf_parser();
855 + $parser = new \Smalot\PdfParser\Parser();
856 + $pdf = $parser->parseFile($pdf_path);
857 + $pages = $pdf->getPages();
858 + $page_count = count($pages);
859 +
860 + if ($page_count > 0) {
861 + //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
862 + return $page_count;
863 + }
864 + } catch (Exception $e) {
865 + //error_log('Smalot PDF parser failed: ' . $e->getMessage());
866 + }
867 +
868 + // Method 2: Try with pdfinfo command (if available)
869 + if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
870 + try {
871 + $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
872 + $output = shell_exec($command);
873 +
874 + if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
875 + $page_count = intval($matches[1]);
876 + if ($page_count > 0) {
877 + //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
878 + return $page_count;
879 + }
880 + }
881 + } catch (Exception $e) {
882 + //error_log('pdfinfo command failed: ' . $e->getMessage());
883 + }
884 + }
885 +
886 + // Method 3: Try to repair PDF and parse again
887 + try {
888 + $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
889 + if ($repaired_path && $repaired_path !== $pdf_path) {
890 + mxchat_load_pdf_parser();
891 + $parser = new \Smalot\PdfParser\Parser();
892 + $pdf = $parser->parseFile($repaired_path);
893 + $pages = $pdf->getPages();
894 + $page_count = count($pages);
895 +
896 + if ($page_count > 0) {
897 + // Replace original with repaired version
898 + copy($repaired_path, $pdf_path);
899 + unlink($repaired_path);
900 + //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
901 + return $page_count;
902 + }
903 +
904 + // Clean up repaired file if it didn't work
905 + unlink($repaired_path);
906 + }
907 + } catch (Exception $e) {
908 + //error_log('PDF repair attempt failed: ' . $e->getMessage());
909 + }
910 +
911 + // Method 4: Manual PDF structure analysis (basic page count)
912 + try {
913 + $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
914 + if ($page_count > 0) {
915 + //error_log('PDF page count determined manually: ' . $page_count . ' pages');
916 + return $page_count;
917 + }
918 + } catch (Exception $e) {
919 + //error_log('Manual PDF analysis failed: ' . $e->getMessage());
920 + }
921 +
922 + //error_log('All PDF parsing methods failed for: ' . $pdf_path);
923 + return false;
924 +}
925 +
926 +/**
927 + * Check if shell_exec is disabled
928 + */
929 +private function mxchat_is_shell_disabled() {
930 + $disabled = explode(',', ini_get('disable_functions'));
931 + return in_array('shell_exec', $disabled);
932 +}
933 +
934 +/**
935 + * Attempt to repair PDF using basic methods
936 + */
937 +private function mxchat_attempt_pdf_repair($pdf_path) {
938 + try {
939 + $content = file_get_contents($pdf_path);
940 + if (!$content) {
941 + return false;
942 + }
943 +
944 + // Check if PDF starts with proper header
945 + if (substr($content, 0, 4) !== '%PDF') {
946 + // Try to find PDF header in the content
947 + $header_pos = strpos($content, '%PDF');
948 + if ($header_pos !== false && $header_pos < 1024) {
949 + // Remove junk before PDF header
950 + $content = substr($content, $header_pos);
951 + $repaired_path = $pdf_path . '.repaired';
952 + file_put_contents($repaired_path, $content);
953 + return $repaired_path;
954 + }
955 + }
956 +
957 + // Check for EOF marker
958 + $content = rtrim($content);
959 + if (!preg_match('/%%EOF\s*$/', $content)) {
960 + // Add EOF marker if missing
961 + $content .= "\n%%EOF";
962 + $repaired_path = $pdf_path . '.repaired';
963 + file_put_contents($repaired_path, $content);
964 + return $repaired_path;
965 + }
966 +
967 + } catch (Exception $e) {
968 + //error_log('PDF repair error: ' . $e->getMessage());
969 + }
970 +
971 + return false;
972 +}
973 +
974 +/**
975 + * Manual PDF page counting by analyzing PDF structure
976 + */
977 +private function mxchat_manual_pdf_page_count($pdf_path) {
978 + try {
979 + $content = file_get_contents($pdf_path);
980 + if (!$content) {
981 + return 0;
982 + }
983 +
984 + // Method 1: Count /Type /Page objects
985 + $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
986 + if ($page_count > 0) {
987 + return $page_count;
988 + }
989 +
990 + // Method 2: Look for /Count in pages object
991 + if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
992 + return intval($matches[1]);
993 + }
994 +
995 + // Method 3: Count page references
996 + $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
997 + if ($page_count > 0) {
998 + return $page_count;
999 + }
1000 +
1001 + } catch (Exception $e) {
1002 + //error_log('Manual PDF analysis error: ' . $e->getMessage());
1003 + }
1004 +
1005 + return 0;
1006 +}
1007 +
1008 +
1009 +public function mxchat_save_inline_prompt() {
1010 + // DEBUG: Log what we're receiving
1011 + //error_log('=== MXCHAT DEBUG ===');
1012 + //error_log('POST data: ' . print_r($_POST, true));
1013 + //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
1014 +
1015 + // Check for nonce security
1016 + check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
1017 +
1018 + // If we get here, nonce passed
1019 + //error_log('Nonce verification PASSED');
1020 +
1021 + // Verify permissions
1022 + if (!current_user_can('manage_options')) {
1023 + wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
1024 + return;
1025 + }
1026 +
1027 + global $wpdb;
1028 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1029 +
1030 + // Validate and sanitize input data
1031 + $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
1032 + $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
1033 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
1034 +
1035 + if ($prompt_id > 0 && !empty($article_content)) {
1036 + // Re-generate the embedding vector for the updated content
1037 + $embedding_vector = $this->mxchat_generate_embedding($article_content);
1038 + if (is_array($embedding_vector)) {
1039 + // Serialize the embedding vector before storing it
1040 + $embedding_vector_serialized = serialize($embedding_vector);
1041 + // Update the prompt in the database
1042 + $updated = $wpdb->update(
1043 + $table_name,
1044 + array(
1045 + 'article_content' => $article_content,
1046 + 'embedding_vector' => $embedding_vector_serialized,
1047 + 'source_url' => $article_url,
1048 + ),
1049 + array('id' => $prompt_id),
1050 + array('%s', '%s', '%s'),
1051 + array('%d')
1052 + );
1053 + if ($updated !== false) {
1054 + wp_send_json_success();
1055 + } else {
1056 + MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
1057 + wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
1058 + }
1059 + } else {
1060 + MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
1061 + wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
1062 + }
1063 + } else {
1064 + wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
1065 + }
1066 +}
1067 +
1068 +
1069 +/**
1070 + * AJAX: Get full content for editing — reassembles chunks if needed.
1071 + * Works for both WordPress DB and Pinecone entries.
1072 + */
1073 +/**
1074 + * Sanitize a knowledge entry's source_url from an AJAX request WITHOUT destroying
1075 + * its identity. sanitize_text_field() strips percent-encoded octets (%20, %D7%A9…),
1076 + * so a percent-encoded URL — every non-ASCII permalink — would md5 to a DIFFERENT
1077 + * id than the one it was stored under: reads miss the entry and saves write an
1078 + * orphan copy while the original keeps its stale text. URLs get esc_url_raw
1079 + * (identity-preserving, matches what import stored); non-URL keys (mxchat://,
1080 + * _ungrouped_) keep the old sanitizer.
1081 + */
1082 +private function sanitize_entry_source_url( $raw ) {
1083 + $raw = trim( (string) $raw );
1084 + if ( preg_match( '#^https?://#i', $raw ) ) {
1085 + return esc_url_raw( $raw );
1086 + }
1087 + return sanitize_text_field( $raw );
1088 +}
1089 +
1090 +public function ajax_mxchat_get_entry_content() {
1091 + check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1092 +
1093 + if ( ! current_user_can('manage_options') ) {
1094 + wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1095 + }
1096 +
1097 + $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1098 + $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1099 + $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1100 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1101 +
1102 + if ( $data_source === 'pinecone' ) {
1103 + // Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
1104 + // destroy them, so re-read the raw value for this branch only.
1105 + $vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1106 + // Pinecone: fetch vectors by source_url, reassemble chunks
1107 + $content = $this->get_pinecone_entry_content( $source_url, $vector_id, $bot_id );
1108 + } else {
1109 + // WordPress DB
1110 + $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
1111 + }
1112 +
1113 + if ( is_wp_error( $content ) ) {
1114 + wp_send_json_error( array( 'message' => $content->get_error_message() ) );
1115 + }
1116 +
1117 + wp_send_json_success( $content );
1118 +}
1119 +
1120 +/**
1121 + * Get content from WordPress DB — reassembles chunks by source_url.
1122 + */
1123 +private function get_wordpress_entry_content( $source_url, $entry_id ) {
1124 + global $wpdb;
1125 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1126 +
1127 + // If we have a source_url, check for chunks
1128 + if ( ! empty( $source_url ) && strpos( $source_url, 'mxchat://' ) !== 0 ) {
1129 + $rows = $wpdb->get_results( $wpdb->prepare(
1130 + "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1131 + $source_url
1132 + ) );
1133 +
1134 + if ( $rows && count( $rows ) > 1 ) {
1135 + // Multiple rows = chunked. Reassemble.
1136 + $chunks = array();
1137 + foreach ( $rows as $row ) {
1138 + $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1139 + $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1140 + $chunks[ $index ] = $parsed['text'];
1141 + }
1142 + ksort( $chunks );
1143 + return array(
1144 + 'content' => implode( "\n\n", $chunks ),
1145 + 'source_url' => $source_url,
1146 + 'is_chunked' => true,
1147 + 'chunk_count' => count( $chunks ),
1148 + 'content_type' => $rows[0]->content_type,
1149 + );
1150 + } elseif ( $rows && count( $rows ) === 1 ) {
1151 + $parsed = MxChat_Chunker::parse_stored_chunk( $rows[0]->article_content );
1152 + return array(
1153 + 'content' => $parsed['text'],
1154 + 'source_url' => $source_url,
1155 + 'entry_id' => $rows[0]->id,
1156 + 'is_chunked' => false,
1157 + 'content_type' => $rows[0]->content_type,
1158 + );
1159 + }
1160 + }
1161 +
1162 + // Fallback: fetch by ID
1163 + if ( $entry_id > 0 ) {
1164 + $row = $wpdb->get_row( $wpdb->prepare(
1165 + "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1166 + $entry_id
1167 + ) );
1168 + if ( $row ) {
1169 + $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1170 + return array(
1171 + 'content' => $parsed['text'],
1172 + 'source_url' => $row->source_url,
1173 + 'entry_id' => $row->id,
1174 + 'is_chunked' => false,
1175 + 'content_type' => $row->content_type,
1176 + );
1177 + }
1178 + }
1179 +
1180 + return new WP_Error( 'not_found', 'Entry not found.' );
1181 +}
1182 +
1183 +/**
1184 + * Get content from Pinecone — fetches vectors by source_url, reassembles chunks.
1185 + */
1186 +private function get_pinecone_entry_content( $source_url, $entry_id, $bot_id ) {
1187 + if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1188 + return new WP_Error( 'pinecone_unavailable', 'Pinecone manager not available.' );
1189 + }
1190 +
1191 + // Get Pinecone config
1192 + if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1193 + $pinecone_options = get_option('mxchat_pinecone_addon_options');
1194 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1195 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1196 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1197 + } else {
1198 + $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1199 + $api_key = $bot_config['api_key'] ?? '';
1200 + $host = $bot_config['host'] ?? '';
1201 + $namespace = $bot_config['namespace'] ?? '';
1202 + }
1203 +
1204 + if ( empty($host) || empty($api_key) ) {
1205 + return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
1206 + }
1207 +
1208 + // Document index (plan 362c31): same ids, read through the documents API.
1209 + if ( class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index( $bot_id ) ) {
1210 + $docs_cfg = array( 'api_key' => $api_key, 'host' => $host, 'namespace' => $namespace );
1211 + if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1212 + $doc_ids = array( $entry_id );
1213 + } else {
1214 + $doc_ids = array_merge( array( md5( $source_url ) ), MxChat_Pinecone_Documents::list_ids( md5( $source_url ) . '_chunk_', $docs_cfg ) );
1215 + }
1216 + $docs = MxChat_Pinecone_Documents::fetch( $doc_ids, $docs_cfg, array( 'text', 'chunk_index', 'type' ) );
1217 + if ( empty($docs) ) {
1218 + return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1219 + }
1220 + $doc_chunks = array();
1221 + $doc_type = 'content';
1222 + foreach ( $docs as $doc ) {
1223 + $doc_meta = $doc['metadata'] ?? array();
1224 + $doc_chunks[ intval( $doc_meta['chunk_index'] ?? 0 ) ] = $doc_meta['text'] ?? '';
1225 + $doc_type = $doc_meta['type'] ?? 'content';
1226 + }
1227 + ksort( $doc_chunks );
1228 + return array(
1229 + 'content' => implode( "\n\n", $doc_chunks ),
1230 + 'source_url' => $source_url,
1231 + 'is_chunked' => count($doc_chunks) > 1,
1232 + 'chunk_count' => count($doc_chunks),
1233 + 'content_type' => $doc_type,
1234 + );
1235 + }
1236 +
1237 + // Manual entries carry no source_url (their vector id is a minted manual_* string,
1238 + // not md5 of anything the row can hand us) — fetch the exact vector instead.
1239 + // '_ungrouped_' is the table view's synthetic display key for such rows.
1240 + if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1241 + $vector_ids = array( $entry_id );
1242 + } else {
1243 + // List vectors with the source_url prefix
1244 + $base_id = md5( $source_url );
1245 + $vector_ids = array( $base_id );
1246 +
1247 + // Find chunk vectors
1248 + // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1249 + // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1250 + $list_url = "https://{$host}/vectors/list";
1251 + $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1252 + if ( ! empty($namespace) ) {
1253 + $list_params['namespace'] = $namespace;
1254 + }
1255 +
1256 + $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1257 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1258 + 'timeout' => 15,
1259 + ) );
1260 +
1261 + if ( ! is_wp_error($list_resp) ) {
1262 + $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1263 + if ( ! empty($list_data['vectors']) ) {
1264 + foreach ( $list_data['vectors'] as $v ) {
1265 + $vector_ids[] = $v['id'];
1266 + }
1267 + }
1268 + }
1269 + }
1270 +
1271 + // Fetch vectors with metadata
1272 + // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1273 + // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1274 + // the query string explicitly.
1275 + $fetch_query = array();
1276 + foreach ( $vector_ids as $fetch_vid ) {
1277 + $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1278 + }
1279 + if ( ! empty($namespace) ) {
1280 + $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1281 + }
1282 +
1283 + $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1284 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1285 + 'timeout' => 15,
1286 + ) );
1287 +
1288 + if ( is_wp_error($fetch_resp) ) {
1289 + return new WP_Error( 'pinecone_fetch', 'Failed to fetch from Pinecone.' );
1290 + }
1291 +
1292 + $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1293 + $vectors = $fetch_data['vectors'] ?? array();
1294 +
1295 + if ( empty($vectors) ) {
1296 + return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1297 + }
1298 +
1299 + // Reassemble chunks
1300 + $chunks = array();
1301 + $content_type = 'content';
1302 + foreach ( $vectors as $vid => $vector ) {
1303 + $meta = $vector['metadata'] ?? array();
1304 + $text = $meta['text'] ?? '';
1305 + $index = $meta['chunk_index'] ?? 0;
1306 + $content_type = $meta['type'] ?? 'content';
1307 + $chunks[ intval($index) ] = $text;
1308 + }
1309 + ksort( $chunks );
1310 +
1311 + return array(
1312 + 'content' => implode( "\n\n", $chunks ),
1313 + 'source_url' => $source_url,
1314 + 'is_chunked' => count($chunks) > 1,
1315 + 'chunk_count' => count($chunks),
1316 + 'content_type' => $content_type,
1317 + );
1318 +}
1319 +
1320 +/**
1321 + * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1322 + * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1323 + * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1324 + */
1325 +public function ajax_mxchat_inspect_entry() {
1326 + check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1327 +
1328 + if ( ! current_user_can('manage_options') ) {
1329 + wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1330 + }
1331 +
1332 + // Same identity-preserving read the Edit path uses: sanitize_text_field()
1333 + // strips percent-encoded octets, so a non-ASCII permalink would look up a
1334 + // different string (WordPress DB) and md5 to a different id (Pinecone).
1335 + $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1336 + $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1337 + $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1338 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1339 +
1340 + if ( $data_source === 'pinecone' ) {
1341 + // Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
1342 + // destroy them, so re-read the raw value for this branch only.
1343 + $vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1344 + $result = $this->inspect_pinecone_entry( $source_url, $vector_id, $bot_id );
1345 + } else {
1346 + $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1347 + }
1348 +
1349 + if ( is_wp_error( $result ) ) {
1350 + wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1351 + }
1352 +
1353 + wp_send_json_success( $result );
1354 +}
1355 +
1356 +/**
1357 + * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1358 + * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1359 + * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1360 + */
1361 +private function inspect_wordpress_entry( $source_url, $entry_id ) {
1362 + global $wpdb;
1363 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1364 +
1365 + $rows = array();
1366 +
1367 + // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1368 + // Direct Content entries (the spec's manual-entry case), which share one
1369 + // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1370 + // display key (invented by the table view for rows with no source_url) is
1371 + // excluded; those fall through to the entry_id lookup below.
1372 + if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1373 + $rows = $wpdb->get_results( $wpdb->prepare(
1374 + "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1375 + $source_url
1376 + ) );
1377 + }
1378 +
1379 + // Fallback / manual "Direct Content" entries: fetch the single row by id.
1380 + if ( empty( $rows ) && $entry_id > 0 ) {
1381 + $row = $wpdb->get_row( $wpdb->prepare(
1382 + "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1383 + $entry_id
1384 + ) );
1385 + if ( $row ) {
1386 + $rows = array( $row );
1387 + }
1388 + }
1389 +
1390 + if ( empty( $rows ) ) {
1391 + return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1392 + }
1393 +
1394 + $chunks = array();
1395 + $content_type = '';
1396 + foreach ( $rows as $row ) {
1397 + $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1398 + $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1399 + $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1400 + $content_type = $row->content_type;
1401 + $chunks[] = array(
1402 + 'index' => $index,
1403 + 'text' => $text,
1404 + 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1405 + 'row_id' => intval( $row->id ),
1406 + );
1407 + }
1408 +
1409 + usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1410 +
1411 + $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1412 +
1413 + return array(
1414 + 'store' => 'wordpress',
1415 + 'source_url' => $source_url,
1416 + 'content_type' => $content_type,
1417 + 'is_chunked' => count( $chunks ) > 1,
1418 + 'chunk_count' => count( $chunks ),
1419 + 'assembled' => $assembled,
1420 + 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1421 + 'chunks' => array_values( $chunks ),
1422 + // WP-DB storage carries no separate vector metadata; surface that fact
1423 + // rather than letting the owner guess (the spec's taxonomy question).
1424 + 'metadata' => array(),
1425 + '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'),
1426 + );
1427 +}
1428 +
1429 +/**
1430 + * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1431 + * but keeps each vector's text + metadata instead of imploding, so the owner can
1432 + * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1433 + * are present per chunk. READ-ONLY.
1434 + */
1435 +private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1436 + if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1437 + return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1438 + }
1439 +
1440 + if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1441 + $pinecone_options = get_option('mxchat_pinecone_addon_options');
1442 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1443 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1444 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1445 + } else {
1446 + $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1447 + $api_key = $bot_config['api_key'] ?? '';
1448 + $host = $bot_config['host'] ?? '';
1449 + $namespace = $bot_config['namespace'] ?? '';
1450 + }
1451 +
1452 + if ( empty($host) || empty($api_key) ) {
1453 + return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1454 + }
1455 +
1456 + // Document index (plan 362c31): the documents API hands back the same
1457 + // id => {metadata} map the classic fetch does, so the inspection below is shared.
1458 + // Manual entries carry no source_url (their vector id is a minted manual_* string,
1459 + // not md5 of anything the row can hand us) — fetch the exact vector instead, the
1460 + // way get_pinecone_entry_content() does. '_ungrouped_' is the table view's
1461 + // synthetic display key for such rows.
1462 + $exact_id = ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) );
1463 +
1464 + if ( class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index( $bot_id ) ) {
1465 + $docs_cfg = array( 'api_key' => $api_key, 'host' => $host, 'namespace' => $namespace );
1466 + if ( $exact_id ) {
1467 + $vector_ids = array( $entry_id );
1468 + } else {
1469 + $vector_ids = array_merge( array( md5( $source_url ) ), MxChat_Pinecone_Documents::list_ids( md5( $source_url ) . '_chunk_', $docs_cfg ) );
1470 + }
1471 + $vectors = MxChat_Pinecone_Documents::fetch( $vector_ids, $docs_cfg );
1472 + if ( empty($vectors) ) {
1473 + return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1474 + }
1475 + } else {
1476 + if ( $exact_id ) {
1477 + $vector_ids = array( $entry_id );
1478 + } else {
1479 + $base_id = md5( $source_url );
1480 + $vector_ids = array( $base_id );
1481 +
1482 + // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1483 + // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1484 + $list_url = "https://{$host}/vectors/list";
1485 + $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1486 + if ( ! empty($namespace) ) {
1487 + $list_params['namespace'] = $namespace;
1488 + }
1489 +
1490 + $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1491 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1492 + 'timeout' => 15,
1493 + ) );
1494 +
1495 + if ( ! is_wp_error($list_resp) ) {
1496 + $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1497 + if ( ! empty($list_data['vectors']) ) {
1498 + foreach ( $list_data['vectors'] as $v ) {
1499 + $vector_ids[] = $v['id'];
1500 + }
1501 + }
1502 + }
1503 + } // end md5(source_url) + chunk-prefix ids
1504 +
1505 + // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1506 + // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1507 + // the query string explicitly.
1508 + $fetch_query = array();
1509 + foreach ( $vector_ids as $fetch_vid ) {
1510 + $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1511 + }
1512 + if ( ! empty($namespace) ) {
1513 + $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1514 + }
1515 +
1516 + $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1517 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1518 + 'timeout' => 15,
1519 + ) );
1520 +
1521 + if ( is_wp_error($fetch_resp) ) {
1522 + return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1523 + }
1524 +
1525 + $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1526 + $vectors = $fetch_data['vectors'] ?? array();
1527 +
1528 + if ( empty($vectors) ) {
1529 + return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1530 + }
1531 + } // end classic (vector index) read
1532 +
1533 + // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1534 + // what is (and is NOT) stored per vector.
1535 + $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1536 + $chunks = array();
1537 + $content_type = '';
1538 + foreach ( $vectors as $vid => $vector ) {
1539 + $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1540 + $text = $meta['text'] ?? '';
1541 + $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1542 + $content_type = $meta['type'] ?? $content_type;
1543 +
1544 + $clean_meta = array();
1545 + foreach ( $meta_fields as $field ) {
1546 + if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1547 + $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1548 + }
1549 + }
1550 +
1551 + $chunks[] = array(
1552 + 'index' => $index,
1553 + 'text' => $text,
1554 + 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1555 + 'vector_id' => (string) $vid,
1556 + 'metadata' => $clean_meta,
1557 + );
1558 + }
1559 +
1560 + usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1561 +
1562 + $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1563 +
1564 + return array(
1565 + 'store' => 'pinecone',
1566 + 'source_url' => $source_url,
1567 + 'content_type' => $content_type,
1568 + 'is_chunked' => count( $chunks ) > 1,
1569 + 'chunk_count' => count( $chunks ),
1570 + 'assembled' => $assembled,
1571 + 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1572 + 'chunks' => array_values( $chunks ),
1573 + 'metadata' => array(),
1574 + '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'),
1575 + );
1576 +}
1577 +
1578 +/**
1579 + * AJAX: Save edited content — re-chunks and re-embeds as needed.
1580 + * Works for both WordPress DB and Pinecone entries.
1581 + */
1582 +public function ajax_mxchat_save_entry_content() {
1583 + check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1584 +
1585 + if ( ! current_user_can('manage_options') ) {
1586 + wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1587 + }
1588 +
1589 + $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1590 + $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1591 + $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
1592 + $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1593 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1594 + $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
1595 +
1596 + if ( empty($content) ) {
1597 + wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
1598 + }
1599 +
1600 + // Get the embedding API key
1601 + $options = get_option('mxchat_options', array());
1602 + $api_key = '';
1603 +
1604 + if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
1605 + $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1606 + $api_key = $bot_options['api_key'] ?? '';
1607 + }
1608 + if ( empty($api_key) ) {
1609 + $api_key = $options['api_key'] ?? '';
1610 + }
1611 +
1612 + if ( $data_source === 'pinecone' ) {
1613 + // Pinecone branch. The WP-DB manual-entry delete below must never run here:
1614 + // Pinecone ids are strings, and absint() on a digit-leading md5 hash would
1615 + // yield a real (unrelated) WP row id.
1616 + $raw_vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1617 + $is_manual_single = empty($source_url) || strpos($source_url, '_ungrouped_') === 0;
1618 + $is_manual_chunked = strpos($source_url, 'mxchat://') === 0;
1619 +
1620 + if ( $is_manual_single || $is_manual_chunked ) {
1621 + // Manual content: remove the old vectors first, then store as fresh manual
1622 + // content — submit_content_to_db mints a new unique identity (manual_* id
1623 + // for a single vector, an mxchat:// chunk prefix if it now chunks).
1624 + if ( $is_manual_chunked ) {
1625 + // Minted identity: base + chunk vectors share the md5(mxchat://...) prefix.
1626 + MxChat_Utils::delete_chunks_for_url( $source_url, $bot_id );
1627 + } elseif ( ! empty($raw_vector_id) && class_exists('MxChat_Pinecone_Manager') ) {
1628 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
1629 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options( $bot_id );
1630 + if ( ! empty($pinecone_options['mxchat_pinecone_api_key']) && ! empty($pinecone_options['mxchat_pinecone_host']) ) {
1631 + $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
1632 + $raw_vector_id,
1633 + $pinecone_options['mxchat_pinecone_api_key'],
1634 + $pinecone_options['mxchat_pinecone_host'],
1635 + $pinecone_options['mxchat_pinecone_namespace'] ?? ''
1636 + );
1637 + }
1638 + }
1639 + $result = MxChat_Utils::submit_content_to_db( $content, '', $api_key, null, $bot_id, $content_type );
1640 + } else {
1641 + // URL-sourced entry: identity is md5(source_url). submit_content_to_db
1642 + // handles delete-old-chunks → re-chunk → re-embed → store, and sweeps
1643 + // stale chunk vectors when the content now fits in a single vector.
1644 + $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, md5($source_url), $bot_id, $content_type );
1645 + }
1646 + } else {
1647 + global $wpdb;
1648 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1649 +
1650 + // If source_url is empty but we have an entry_id, look it up
1651 + if ( empty($source_url) && $entry_id > 0 ) {
1652 + $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1653 + if ( $row && ! empty($row->source_url) ) {
1654 + $source_url = $row->source_url;
1655 + }
1656 + }
1657 +
1658 + // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1659 + // so submit_content_to_db creates a replacement instead of a duplicate
1660 + // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1661 + $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1662 + if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
1663 + $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1664 + // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1665 + // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1666 + if ( $is_legacy_manual ) {
1667 + $source_url = '';
1668 + }
1669 + }
1670 +
1671 + // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1672 + $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1673 +
1674 + // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1675 + $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1676 + }
1677 +
1678 + if ( is_wp_error($result) ) {
1679 + wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1680 + }
1681 +
1682 + wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
1683 +}
1684 +
1685 +public function mxchat_get_pdf_processing_status($pdf_url) {
1686 + $pdf_url = esc_url_raw($pdf_url);
1687 + $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
1688 +
1689 + if (!$status || !is_array($status)) {
1690 + return false;
1691 + }
1692 +
1693 + // Check for stalled processing (no updates for 5 minutes)
1694 + if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
1695 + $status['status'] = 'error';
1696 + $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
1697 +
1698 + // Save the updated status
1699 + set_transient(
1700 + sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
1701 + array_map('sanitize_text_field', $status),
1702 + DAY_IN_SECONDS
1703 + );
1704 + }
1705 +
1706 + $result = array(
1707 + 'total_pages' => absint($status['total_pages']),
1708 + 'processed_pages' => absint($status['processed_pages']),
1709 + 'failed_pages' => absint($status['failed_pages'] ?? 0),
1710 + 'percentage' => ($status['total_pages'] > 0)
1711 + ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
1712 + : 0,
1713 + 'status' => sanitize_text_field($status['status']),
1714 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1715 + 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
1716 + 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
1717 + );
1718 +
1719 + // Add error message if present
1720 + if (isset($status['error']) && !empty($status['error'])) {
1721 + $result['error'] = sanitize_text_field($status['error']);
1722 + }
1723 +
1724 + return $result;
1725 +}
1726 +
1727 +
1728 +public function mxchat_handle_sitemap_submission() {
1729 + // Check if the form was submitted and verify permissions
1730 + if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
1731 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
1732 + }
1733 +
1734 + // Verify nonce
1735 + check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
1736 +
1737 + // Validate URL
1738 + if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
1739 + set_transient('mxchat_admin_notice_error',
1740 + esc_html__('Please provide a valid URL.', 'mxchat'),
1741 + 30
1742 + );
1743 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1744 + exit;
1745 + }
1746 +
1747 + $submitted_url = esc_url_raw($_POST['sitemap_url']);
1748 +
1749 + // Convert Google Drive sharing URLs to direct download URLs
1750 + if ( strpos($submitted_url, 'drive.google.com') !== false ) {
1751 + $file_id = '';
1752 + if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
1753 + $file_id = $m[1];
1754 + } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
1755 + $file_id = $m[1];
1756 + }
1757 + if ( ! empty($file_id) ) {
1758 + $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
1759 + }
1760 + }
1761 +
1762 + // Get bot_id from form submission
1763 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1764 +
1765 + // Get bot-specific options and validate the embedding decision —
1766 + // custom-provider-aware (plan cbd5fd).
1767 + $bot_options = $this->get_bot_options($bot_id);
1768 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1769 +
1770 + $preflight = MxChat_Utils::embedding_preflight($options);
1771 + if (!$preflight['ok']) {
1772 + set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
1773 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1774 + exit;
1775 + }
1776 + $api_key = $preflight['api_key'];
1777 +
1778 + // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1779 + // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1780 + // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1781 + // from the site's own media library, which route through this same call).
1782 + // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1783 + // the browser-only Accept-Language fingerprint is dropped so it stays
1784 + // coherent with a bot identity.
1785 + $response = wp_remote_get($submitted_url, array(
1786 + 'timeout' => 30,
1787 + 'sslverify' => false,
1788 + 'user-agent' => mxchat_ingest_user_agent(),
1789 + 'headers' => array(
1790 + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1791 + ),
1792 + ));
1793 +
1794 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1795 + $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1796 + set_transient('mxchat_admin_notice_error',
1797 + sprintf(
1798 + esc_html__('Failed to fetch the URL: %s', 'mxchat'),
1799 + esc_html($error_message)
1800 + ),
1801 + 30
1802 + );
1803 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1804 + exit;
1805 + }
1806 +
1807 + $content_type = wp_remote_retrieve_header($response, 'content-type');
1808 + $body_content = wp_remote_retrieve_body($response);
1809 +
1810 + if (empty($body_content)) {
1811 + set_transient('mxchat_admin_notice_error',
1812 + esc_html__('Empty response received from URL.', 'mxchat'),
1813 + 30
1814 + );
1815 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1816 + exit;
1817 + }
1818 +
1819 + // Handle PDF URL
1820 + if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
1821 + $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
1822 +
1823 + if ($result === 'queued') {
1824 + set_transient('mxchat_admin_notice_success',
1825 + esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
1826 + 30
1827 + );
1828 + } else {
1829 + set_transient('mxchat_admin_notice_error',
1830 + esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
1831 + 30
1832 + );
1833 + }
1834 +
1835 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1836 + exit;
1837 + }
1838 +
1839 + // Handle Sitemap XML
1840 + if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
1841 + libxml_use_internal_errors(true);
1842 + $xml = simplexml_load_string($body_content);
1843 + $xml_errors = libxml_get_errors();
1844 + libxml_clear_errors();
1845 +
1846 + if ($xml === false || !empty($xml_errors)) {
1847 + set_transient('mxchat_admin_notice_error',
1848 + esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
1849 + 30
1850 + );
1851 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1852 + exit;
1853 + }
1854 +
1855 + $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
1856 +
1857 + if ($result === 'queued') {
1858 + set_transient('mxchat_admin_notice_success',
1859 + esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1860 + 30
1861 + );
1862 + } else {
1863 + // Surface the reason the handler already computed (embedding pre-flight,
1864 + // empty sitemap, queue failure). The old message pointed at the status
1865 + // area, which is empty on this path — nothing was ever queued.
1866 + if (is_string($result) && $result !== '') {
1867 + set_transient('mxchat_admin_notice_error',
1868 + esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result),
1869 + 30
1870 + );
1871 + } else {
1872 + set_transient('mxchat_admin_notice_error',
1873 + esc_html__('Failed to queue sitemap processing.', 'mxchat'),
1874 + 30
1875 + );
1876 + }
1877 + }
1878 +
1879 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1880 + exit;
1881 + }
1882 +
1883 + // Handle Regular URL (single page)
1884 + $page_content = $this->mxchat_extract_main_content($body_content);
1885 + $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
1886 +
1887 + //error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
1888 + //error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
1889 + //error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
1890 + //error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
1891 +
1892 + if (empty($sanitized_content)) {
1893 + set_transient('mxchat_admin_notice_error',
1894 + esc_html__('No valid content found on the provided URL.', 'mxchat'),
1895 + 30
1896 + );
1897 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1898 + exit;
1899 + }
1900 +
1901 + // For single URLs, process immediately using submit_content_to_db
1902 + // This handles chunking automatically for large content
1903 + $db_result = MxChat_Utils::submit_content_to_db(
1904 + $sanitized_content,
1905 + $submitted_url,
1906 + $api_key,
1907 + null,
1908 + $bot_id,
1909 + 'url', // content_type
1910 + MxChat_Utils::extract_page_title($body_content) // the page's own title (plan d9ee66)
1911 + );
1912 +
1913 + if (is_wp_error($db_result)) {
1914 + $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1915 + set_transient('mxchat_admin_notice_error', $error_message, 30);
1916 + } else {
1917 + $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1918 + set_transient('mxchat_admin_notice_success', $success_message, 30);
1919 + }
1920 +
1921 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1922 + exit;
1923 +}
1924 +
1925 +
1926 +public function mxchat_get_single_url_status() {
1927 + $status = get_transient('mxchat_single_url_status');
1928 + if (!$status) {
1929 + return null;
1930 + }
1931 +
1932 + // Add human-readable time
1933 + if (isset($status['timestamp'])) {
1934 + $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1935 + }
1936 +
1937 + return $status;
1938 +}
1939 +
1940 +public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1941 + if (!current_user_can('manage_options')) {
1942 + return false;
1943 + }
1944 +
1945 + try {
1946 + $sitemap_url = esc_url_raw($sitemap_url);
1947 +
1948 + if (!$xml || !is_object($xml)) {
1949 + throw new Exception(__('Invalid XML object provided', 'mxchat'));
1950 + }
1951 +
1952 + // Get bot-specific embedding API for validation
1953 + $bot_options = $this->get_bot_options($bot_id);
1954 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1955 +
1956 + // Test the embedding API before processing
1957 + $test_phrase = "Test embedding generation for MxChat";
1958 + $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1959 +
1960 + if (is_string($test_result)) {
1961 + throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1962 + }
1963 +
1964 + if (!is_array($test_result)) {
1965 + throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1966 + }
1967 +
1968 + // Extract URLs from sitemap
1969 + $urls = array();
1970 + foreach ($xml->url as $url_element) {
1971 + $url = esc_url_raw((string)$url_element->loc);
1972 + if ($url) {
1973 + $urls[] = array('url' => $url);
1974 + }
1975 + }
1976 +
1977 + $total_urls = count($urls);
1978 +
1979 + if ($total_urls < 1) {
1980 + throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1981 + }
1982 +
1983 + // Create unique queue ID
1984 + $queue_id = 'sitemap_' . md5($sitemap_url . time());
1985 +
1986 + // Add URLs to queue
1987 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1988 +
1989 + if ($queued_count === 0) {
1990 + throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1991 + }
1992 +
1993 + // Store queue metadata
1994 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1995 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1996 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1997 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1998 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1999 +
2000 + // Store queue ID in transient for status tracking
2001 + set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
2002 + set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
2003 +
2004 + return 'queued';
2005 +
2006 + } catch (Exception $e) {
2007 + $error_message = $e->getMessage();
2008 + //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
2009 +
2010 + return $error_message;
2011 + }
2012 +
2013 +}
2014 +
2015 +/**
2016 + * Remove shortcode tags but preserve the content inside them
2017 + * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
2018 + *
2019 + * @param string $content The content containing shortcodes
2020 + * @return string Content with shortcode tags removed but inner content preserved
2021 + */
2022 +/**
2023 + * Single-pass HTML entity decode for text entering the knowledge base.
2024 + * The corpus should hold what a human reads: a stored `&amp;` consumes
2025 + * extra tokens, distorts the vector away from the form a visitor's
2026 + * question uses, and can be quoted back verbatim in an answer.
2027 + * Deliberately NOT looped to a fixed point — a stored `&amp;amp;` is a
2028 + * legitimate literal `&amp;` and must not collapse further (data loss).
2029 + * UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly
2030 + * paths call this at their output points so the treatment cannot drift.
2031 + * (Plan d2c92e.)
2032 + */
2033 +private function mxchat_decode_entities_for_indexing($text) {
2034 + return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2035 +}
2036 +
2037 +/**
2038 + * Price lines for a product's indexed text, pinned to the store's BASE currency.
2039 + *
2040 + * The four product assembly paths each used to call get_woocommerce_currency_symbol()
2041 + * with no argument, which resolves the currency active on the CURRENT request.
2042 + * Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per
2043 + * request, so whichever currency the store happened to be serving when an import ran
2044 + * was frozen into every product it indexed. The amounts have the mirror problem: the
2045 + * woocommerce_product_get_* filters convert prices in the 'view' context but not in
2046 + * 'edit', so a converted amount could be paired with an unconverted symbol and produce
2047 + * a price that is not merely wrong but incoherent.
2048 + *
2049 + * Base currency option + 'edit' context makes both halves agree and makes the output
2050 + * independent of when the import ran. The currency CODE is emitted alongside the symbol
2051 + * so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols.
2052 + * (Plan 7403ec.)
2053 + */
2054 +private function mxchat_product_price_lines($product) {
2055 + if (!is_object($product) || !method_exists($product, 'get_regular_price')) {
2056 + return '';
2057 + }
2058 +
2059 + $currency = get_option('woocommerce_currency');
2060 + $currency = is_string($currency) ? trim($currency) : '';
2061 + $symbol = ($currency !== '')
2062 + ? get_woocommerce_currency_symbol($currency)
2063 + : get_woocommerce_currency_symbol();
2064 + $symbol = $this->mxchat_decode_entities_for_indexing($symbol);
2065 +
2066 + $regular_price = $product->get_regular_price('edit');
2067 + $sale_price = $product->get_sale_price('edit');
2068 + $price = $product->get_price('edit');
2069 +
2070 + $lines = '';
2071 +
2072 + if (!empty($regular_price)) {
2073 + $lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n";
2074 + } elseif (!empty($price)) {
2075 + $lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n";
2076 + }
2077 +
2078 + if (!empty($sale_price) && $sale_price !== $regular_price) {
2079 + $lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n";
2080 + }
2081 +
2082 + if ($product->is_type('variable')) {
2083 + list($min_price, $max_price) = $this->mxchat_variation_price_range($product);
2084 + if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) {
2085 + $lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol)
2086 + . " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n";
2087 + }
2088 + }
2089 +
2090 + return $lines;
2091 +}
2092 +
2093 +/**
2094 + * One indexed price amount, labelled with its currency code.
2095 + *
2096 + * "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol
2097 + * is kept so a quoted price still reads naturally. Falls back to the old symbol-only
2098 + * shape when WooCommerce has no base currency configured, and drops the parenthetical
2099 + * when the symbol is absent or IS the code (several currencies have no distinct glyph).
2100 + */
2101 +private function mxchat_format_indexed_price($amount, $currency, $symbol) {
2102 + $amount = (string) $amount;
2103 +
2104 + if ($currency === '') {
2105 + return $symbol . $amount;
2106 + }
2107 +
2108 + if ($symbol === '' || $symbol === $currency) {
2109 + return $currency . ' ' . $amount;
2110 + }
2111 +
2112 + return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')';
2113 +}
2114 +
2115 +/**
2116 + * Min/max variation price read from the variations themselves in 'edit' context.
2117 + *
2118 + * get_variation_price() reads WooCommerce's display price cache, which multi-currency
2119 + * plugins populate with converted values — the same defect the rest of this helper
2120 + * exists to remove. Returns raw stored strings (not floats) so the indexed text keeps
2121 + * the store's own price formatting, and (null, null) when no variation carries a price.
2122 + */
2123 +private function mxchat_variation_price_range($product) {
2124 + $min_raw = null;
2125 + $max_raw = null;
2126 + $min_val = null;
2127 + $max_val = null;
2128 +
2129 + $children = method_exists($product, 'get_children') ? $product->get_children() : array();
2130 +
2131 + foreach ($children as $child_id) {
2132 + $variation = wc_get_product($child_id);
2133 + if (!$variation) {
2134 + continue;
2135 + }
2136 + $raw = $variation->get_price('edit');
2137 + if ($raw === '' || $raw === null) {
2138 + continue;
2139 + }
2140 + $val = (float) $raw;
2141 + if ($min_val === null || $val < $min_val) {
2142 + $min_val = $val;
2143 + $min_raw = $raw;
2144 + }
2145 + if ($max_val === null || $val > $max_val) {
2146 + $max_val = $val;
2147 + $max_raw = $raw;
2148 + }
2149 + }
2150 +
2151 + return array($min_raw, $max_raw);
2152 +}
2153 +
2154 +private function strip_shortcode_tags_preserve_content($content) {
2155 + // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
2156 + // Content between tags is inherently preserved since only brackets are targeted
2157 + $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
2158 + return ($result !== null) ? $result : $content;
2159 +}
2160 +
2161 +public function mxchat_sanitize_content_for_api($content) {
2162 + //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
2163 +
2164 + // Remove shortcode tags but PRESERVE content inside them
2165 + $content = $this->strip_shortcode_tags_preserve_content($content);
2166 +
2167 + // Remove script, style tags, and HTML comments
2168 + $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
2169 + $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
2170 + $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
2171 +
2172 + // Remove all HTML tags and decode HTML entities
2173 + $content = wp_strip_all_tags($content);
2174 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
2175 +
2176 + // Normalize whitespace but preserve paragraph breaks
2177 + // First, normalize line endings to \n
2178 + $content = str_replace(["\r\n", "\r"], "\n", $content);
2179 + // Replace multiple spaces/tabs with single space, but preserve newlines
2180 + $content = preg_replace('/[ \t]+/', ' ', $content);
2181 + // Replace 3+ newlines with 2 newlines (max 2 blank lines)
2182 + $content = preg_replace('/\n{3,}/', "\n\n", $content);
2183 + // Trim each line
2184 + $lines = explode("\n", $content);
2185 + $lines = array_map('trim', $lines);
2186 + $content = implode("\n", $lines);
2187 + // Final trim
2188 + $content = trim($content);
2189 +
2190 + // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
2191 + $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
2192 +
2193 + // Remove NULL bytes which can cause database errors
2194 + $content = str_replace("\0", "", $content);
2195 +
2196 + // Ensure valid UTF-8 encoding
2197 + $content = wp_check_invalid_utf8($content);
2198 +
2199 + // Remove extremely long runs with no whitespace (base64 blobs, minified JS).
2200 + // Counts CHARACTERS (/u), and never strips a run containing characters from a
2201 + // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar —
2202 + // where a normal paragraph is legitimately one unbroken run.
2203 + $content = preg_replace_callback('/\S{300,}/u', function ($m) {
2204 + return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' ';
2205 + }, $content);
2206 +
2207 + // Remove emoji/symbol blocks only — not the whole supplementary plane, which
2208 + // also holds CJK Extension B ideographs used in real Chinese/Japanese names.
2209 + // A ZWJ (U+200D) BETWEEN stripped pictographs is consumed with them, so a
2210 + // family sequence like 👨‍👩‍👧 leaves no invisible zero-width residue behind
2211 + // (the joiner between NON-emoji characters — Hindi conjuncts — is untouched).
2212 + $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}](?:\x{200D}[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}])*/u', '', $content);
2213 +
2214 + // There is deliberately NO catch-all character allowlist here (plan 209e57;
2215 + // one existed until 3.2.20). Every genuinely dangerous byte is already gone:
2216 + // control characters, null bytes, invalid UTF-8 and the emoji blocks are all
2217 + // stripped above. The allowlist's only remaining effect was to damage scripts
2218 + // nobody thought to enumerate — Unicode Cf (Format) was missing, so it
2219 + // replaced the zero-width joiner/non-joiner with spaces and silently split
2220 + // Persian words (می‌روم → می روم) and broke Hindi conjuncts (क्‍ष → क् ष).
2221 + // Do not add one back; the failure mode of an allowlist is exactly this.
2222 +
2223 + // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized,
2224 + // but cut on a character boundary so a multibyte char is never split mid-sequence)
2225 + $max_length = 65000; // Just under MySQL TEXT field limit
2226 + if (strlen($content) > $max_length) {
2227 + $content = mb_strcut($content, 0, $max_length, 'UTF-8');
2228 + }
2229 +
2230 + //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
2231 + return $content;
2232 +}
2233 +public function mxchat_extract_main_content($html) {
2234 + if (empty($html)) {
2235 + return '';
2236 + }
2237 + try {
2238 + $dom = new DOMDocument;
2239 + libxml_use_internal_errors(true); // Suppress HTML parsing errors
2240 + @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
2241 + $xpath = new DOMXPath($dom);
2242 +
2243 + // For debugging purposes
2244 + $debugEnabled = true; // Set to true to enable debugging output
2245 + $debug = function($message) use ($debugEnabled) {
2246 + if ($debugEnabled) {
2247 + //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
2248 + }
2249 + };
2250 +
2251 + // Direct targeting for Gerow theme posts
2252 + $post_text = $xpath->query('//div[contains(@class, "post-text")]');
2253 + if ($post_text && $post_text->length > 0) {
2254 + $debug("Found post-text directly");
2255 + $content = '';
2256 + foreach ($post_text as $node) {
2257 + $content .= $dom->saveHTML($node);
2258 + }
2259 + if (!empty($content)) {
2260 + $debug("Returning post-text content");
2261 + return $content;
2262 + }
2263 + }
2264 +
2265 + // Try to get the blog details content which contains the post-text
2266 + $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
2267 + if ($blog_details && $blog_details->length > 0) {
2268 + $debug("Found blog-details-content");
2269 + $content = '';
2270 + foreach ($blog_details as $node) {
2271 + $content .= $dom->saveHTML($node);
2272 + }
2273 + if (!empty($content)) {
2274 + $debug("Returning blog-details-content");
2275 + return $content;
2276 + }
2277 + }
2278 +
2279 + // Try to get the article which contains the blog details
2280 + $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
2281 + if ($article && $article->length > 0) {
2282 + $debug("Found article with blog-details-wrap");
2283 + $content = '';
2284 + foreach ($article as $node) {
2285 + $content .= $dom->saveHTML($node);
2286 + }
2287 + if (!empty($content)) {
2288 + $debug("Returning article content");
2289 + return $content;
2290 + }
2291 + }
2292 +
2293 + // Try even broader with the blog-item-wrap
2294 + $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
2295 + if ($blog_item && $blog_item->length > 0) {
2296 + $debug("Found blog-item-wrap");
2297 + $content = '';
2298 + foreach ($blog_item as $node) {
2299 + $content .= $dom->saveHTML($node);
2300 + }
2301 + if (!empty($content)) {
2302 + $debug("Returning blog-item-wrap content");
2303 + return $content;
2304 + }
2305 + }
2306 +
2307 + // Specific Gerow theme path
2308 + $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
2309 + if ($gerow_path && $gerow_path->length > 0) {
2310 + $debug("Found Gerow theme path to post-text");
2311 + $content = '';
2312 + foreach ($gerow_path as $node) {
2313 + $content .= $dom->saveHTML($node);
2314 + }
2315 + if (!empty($content)) {
2316 + $debug("Returning Gerow post-text content");
2317 + return $content;
2318 + }
2319 + }
2320 +
2321 + // Generic blog post selectors
2322 + $selectors = [
2323 + // Blog post specific selectors
2324 + '//div[contains(@class, "post-text")]',
2325 + '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
2326 + '//div[contains(@class, "blog-details-content")]',
2327 + '//article[contains(@class, "blog-details-wrap")]',
2328 + '//div[contains(@class, "entry-content")]',
2329 + '//div[contains(@class, "blog-content")]',
2330 + '//div[contains(@class, "blog-item-wrap")]',
2331 +
2332 + // More general content selectors
2333 + '//div[contains(@class, "page__content")]',
2334 + '//div[contains(@class, "elementor-widget-container")]',
2335 + '//div[contains(@class, "elementor-text-editor")]',
2336 + '//div[contains(@class, "elementor-widget-text-editor")]',
2337 + '//*[contains(@class, "entry-content")]',
2338 + '//*[contains(@class, "post-content")]',
2339 + '//*[contains(@class, "article-content")]',
2340 + '//*[@id="content"]',
2341 + '//*[@id="main-content"]',
2342 + '//section[contains(@class, "blog-area")]',
2343 + '//article',
2344 + '//main',
2345 + '//div[contains(@class, "content")]'
2346 + ];
2347 +
2348 + // First handle Elementor content - get only leaf widget containers to avoid duplicates
2349 + $debug("Checking for Elementor content");
2350 + // Get widget containers that are direct children of widgets (not nested inside other widget containers)
2351 + $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
2352 + if ($elementor_widgets && $elementor_widgets->length > 0) {
2353 + $debug("Found Elementor widgets");
2354 + $seen_content = array(); // Track seen content to avoid duplicates
2355 + $combined_content = '';
2356 + foreach ($elementor_widgets as $widget) {
2357 + $widget_content = $dom->saveHTML($widget);
2358 + if (!empty($widget_content)) {
2359 + // Create a hash of the content to detect duplicates
2360 + $content_hash = md5($widget_content);
2361 + if (!isset($seen_content[$content_hash])) {
2362 + $seen_content[$content_hash] = true;
2363 + $combined_content .= $widget_content;
2364 + }
2365 + }
2366 + }
2367 + if (!empty($combined_content)) {
2368 + $debug("Returning Elementor content");
2369 + return $combined_content;
2370 + }
2371 + }
2372 +
2373 + // Try standard selectors one by one
2374 + foreach ($selectors as $selector) {
2375 + $debug("Trying selector: " . $selector);
2376 + $nodes = $xpath->query($selector);
2377 + if ($nodes && $nodes->length > 0) {
2378 + $debug("Found " . $nodes->length . " matches for selector: " . $selector);
2379 + // Only take the FIRST matching node to avoid duplicate content
2380 + // (pages often have nested or multiple containers with same class)
2381 + $content = $dom->saveHTML($nodes->item(0));
2382 + if (!empty($content)) {
2383 + $debug("Returning content from selector: " . $selector . " (first match only)");
2384 + return $content;
2385 + }
2386 + }
2387 + }
2388 +
2389 + // Manual regex fallback for post-text if DOM methods fail
2390 + $debug("Trying regex fallback");
2391 + if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
2392 + $debug("Found post-text via regex");
2393 + return '<div class="post-text">' . $matches[1] . '</div>';
2394 + }
2395 +
2396 + // Try to extract the blog section as a whole
2397 + $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
2398 + if ($blog_section && $blog_section->length > 0) {
2399 + $debug("Found blog-area section");
2400 + $content = '';
2401 + foreach ($blog_section as $node) {
2402 + $content .= $dom->saveHTML($node);
2403 + }
2404 + if (!empty($content)) {
2405 + $debug("Returning blog-area section content");
2406 + return $content;
2407 + }
2408 + }
2409 +
2410 + // Generic container selectors for non-CMS sites (like .asp pages)
2411 + $debug("Trying generic container selectors");
2412 + $generic_selectors = [
2413 + '//div[@id="main"]',
2414 + '//div[@id="wrapper"]',
2415 + '//div[@id="page"]',
2416 + '//div[@id="site-content"]',
2417 + '//div[contains(@class, "main-content")]',
2418 + '//div[contains(@class, "page-content")]',
2419 + '//div[contains(@class, "site-content")]',
2420 + ];
2421 +
2422 + foreach ($generic_selectors as $selector) {
2423 + $debug("Trying generic selector: " . $selector);
2424 + $nodes = $xpath->query($selector);
2425 + if ($nodes && $nodes->length > 0) {
2426 + $content = $dom->saveHTML($nodes->item(0));
2427 + if (!empty($content)) {
2428 + $debug("Returning content from generic selector: " . $selector);
2429 + return $content;
2430 + }
2431 + }
2432 + }
2433 +
2434 + // Paragraph-based content detection - find regions with substantial text
2435 + $debug("Trying paragraph-based content detection");
2436 + $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
2437 + if ($paragraphs && $paragraphs->length >= 3) {
2438 + $debug("Found " . $paragraphs->length . " substantial paragraphs");
2439 + // Collect all substantial paragraphs and their content
2440 + $paragraph_content = '';
2441 + foreach ($paragraphs as $p) {
2442 + $paragraph_content .= $dom->saveHTML($p) . "\n";
2443 + }
2444 + if (!empty($paragraph_content)) {
2445 + $debug("Returning paragraph-based content");
2446 + return $paragraph_content;
2447 + }
2448 + }
2449 +
2450 + // Improved body fallback - strip nav/header/footer elements first
2451 + $debug("Using improved body fallback");
2452 + $body = $dom->getElementsByTagName('body');
2453 + if ($body->length > 0) {
2454 + // Clone the body to avoid modifying the original DOM
2455 + $body_clone = $body->item(0)->cloneNode(true);
2456 +
2457 + // Remove common non-content elements by tag name
2458 + $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
2459 + foreach ($remove_tags as $tag) {
2460 + $elements = $body_clone->getElementsByTagName($tag);
2461 + // Iterate backwards to safely remove elements
2462 + for ($i = $elements->length - 1; $i >= 0; $i--) {
2463 + $el = $elements->item($i);
2464 + if ($el && $el->parentNode) {
2465 + $el->parentNode->removeChild($el);
2466 + }
2467 + }
2468 + }
2469 +
2470 + // Remove elements with common non-content class names using XPath on the cloned body
2471 + $temp_dom = new DOMDocument();
2472 + @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
2473 + $temp_xpath = new DOMXPath($temp_dom);
2474 +
2475 + $remove_class_patterns = [
2476 + '//*[contains(@class, "nav")]',
2477 + '//*[contains(@class, "menu")]',
2478 + '//*[contains(@class, "sidebar")]',
2479 + '//*[contains(@class, "footer")]',
2480 + '//*[contains(@class, "header")]',
2481 + '//*[contains(@id, "nav")]',
2482 + '//*[contains(@id, "menu")]',
2483 + '//*[contains(@id, "sidebar")]',
2484 + '//*[contains(@id, "footer")]',
2485 + '//*[contains(@id, "header")]',
2486 + ];
2487 +
2488 + foreach ($remove_class_patterns as $pattern) {
2489 + $elements = $temp_xpath->query($pattern);
2490 + if ($elements) {
2491 + for ($i = $elements->length - 1; $i >= 0; $i--) {
2492 + $el = $elements->item($i);
2493 + if ($el && $el->parentNode) {
2494 + $el->parentNode->removeChild($el);
2495 + }
2496 + }
2497 + }
2498 + }
2499 +
2500 + $cleaned_content = $temp_dom->saveHTML();
2501 + if (!empty($cleaned_content)) {
2502 + $debug("Returning cleaned body content");
2503 + return $cleaned_content;
2504 + }
2505 + }
2506 +
2507 + // Last resort: return the original HTML
2508 + $debug("Returning original HTML");
2509 + return $html;
2510 + } catch (Exception $e) {
2511 + //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
2512 + return $html; // Return original HTML if parsing fails
2513 + } finally {
2514 + libxml_clear_errors();
2515 + }
2516 +}
2517 +public function mxchat_get_sitemap_processing_status($sitemap_url) {
2518 + $sitemap_url = esc_url_raw($sitemap_url);
2519 + $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
2520 + $status = get_transient($status_key);
2521 +
2522 + if (!$status || !is_array($status)) {
2523 + return false;
2524 + }
2525 +
2526 + // Auto-complete check: if all URLs are processed but status isn't complete
2527 + if (isset($status['processed_urls']) && isset($status['total_urls']) &&
2528 + $status['processed_urls'] >= $status['total_urls'] &&
2529 + isset($status['status']) && $status['status'] !== 'complete' &&
2530 + $status['status'] !== 'error') {
2531 +
2532 + // Mark as complete
2533 + $status['status'] = 'complete';
2534 + $status['processed_urls'] = $status['total_urls']; // Ensure exact match
2535 +
2536 + // Update the transient with the corrected status
2537 + set_transient($status_key, $status, DAY_IN_SECONDS);
2538 + }
2539 +
2540 + return array(
2541 + 'total_urls' => absint($status['total_urls']),
2542 + 'processed_urls' => absint($status['processed_urls']),
2543 + 'failed_urls' => absint($status['failed_urls'] ?? 0),
2544 + 'percentage' => ($status['total_urls'] > 0)
2545 + ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
2546 + : 0,
2547 + 'status' => sanitize_text_field($status['status']),
2548 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
2549 + 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
2550 + 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
2551 + 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
2552 + );
2553 +}
2554 +
2555 +public function mxchat_ajax_get_status_updates() {
2556 + try {
2557 + // Verify the request
2558 + check_ajax_referer('mxchat_status_nonce', 'nonce');
2559 +
2560 + // Get active queue IDs
2561 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2562 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2563 +
2564 + $sitemap_status = false;
2565 + $pdf_status = false;
2566 +
2567 + // Get sitemap queue status
2568 + if ($sitemap_queue_id) {
2569 + $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2570 + }
2571 +
2572 + // Get PDF queue status
2573 + if ($pdf_queue_id) {
2574 + $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2575 + }
2576 +
2577 + $is_active_processing =
2578 + ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2579 + ($pdf_status && $pdf_status['status'] === 'processing');
2580 +
2581 + // Return JSON response with the status data
2582 + wp_send_json(array(
2583 + 'pdf_status' => $pdf_status,
2584 + 'sitemap_status' => $sitemap_status,
2585 + 'is_processing' => $is_active_processing,
2586 + 'sitemap_queue_id' => $sitemap_queue_id,
2587 + 'pdf_queue_id' => $pdf_queue_id
2588 + ));
2589 +
2590 + } catch (Exception $e) {
2591 + //error_log('MxChat Status Update Error: ' . $e->getMessage());
2592 +
2593 + wp_send_json_error(array(
2594 + 'message' => 'Error getting status updates: ' . $e->getMessage(),
2595 + 'status' => 'error'
2596 + ));
2597 + }
2598 +}
2599 +
2600 +/**
2601 + * Helper function to get queue status data
2602 + */
2603 +private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
2604 + global $wpdb;
2605 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2606 +
2607 + // Get counts by status
2608 + $counts = $wpdb->get_results($wpdb->prepare(
2609 + "SELECT status, COUNT(*) as count
2610 + FROM $table_name
2611 + WHERE queue_id = %s
2612 + GROUP BY status",
2613 + $queue_id
2614 + ), OBJECT_K);
2615 +
2616 + $total = 0;
2617 + $completed = 0;
2618 + $failed = 0;
2619 + $processing = 0;
2620 + $pending = 0;
2621 +
2622 + foreach ($counts as $status => $data) {
2623 + $count = absint($data->count);
2624 + $total += $count;
2625 +
2626 + switch ($status) {
2627 + case 'completed':
2628 + $completed = $count;
2629 + break;
2630 + case 'failed':
2631 + $failed = $count;
2632 + break;
2633 + case 'processing':
2634 + $processing = $count;
2635 + break;
2636 + case 'pending':
2637 + $pending = $count;
2638 + break;
2639 + }
2640 + }
2641 +
2642 + if ($total === 0) {
2643 + return false;
2644 + }
2645 +
2646 + // Calculate percentage
2647 + $percentage = round((($completed + $failed) / $total) * 100);
2648 +
2649 + // Get failed items details (limit to 50)
2650 + $failed_items = array();
2651 + if ($failed > 0) {
2652 + $failed_results = $wpdb->get_results($wpdb->prepare(
2653 + "SELECT item_type, item_data, error_message, attempts, completed_at
2654 + FROM $table_name
2655 + WHERE queue_id = %s
2656 + AND status = 'failed'
2657 + AND attempts >= max_attempts
2658 + ORDER BY id DESC
2659 + LIMIT 50",
2660 + $queue_id
2661 + ));
2662 +
2663 + foreach ($failed_results as $item) {
2664 + $data = json_decode($item->item_data, true);
2665 + $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
2666 +
2667 + $failed_items[] = array(
2668 + 'url' => $url,
2669 + 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
2670 + 'error' => $item->error_message,
2671 + 'retries' => $item->attempts,
2672 + 'time' => strtotime($item->completed_at)
2673 + );
2674 + }
2675 + }
2676 +
2677 + // Get queue metadata
2678 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
2679 +
2680 + // Determine if queue is complete
2681 + $is_complete = ($pending === 0 && $processing === 0);
2682 +
2683 + // Get last update time
2684 + $last_update = $wpdb->get_var($wpdb->prepare(
2685 + "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
2686 + FROM $table_name
2687 + WHERE queue_id = %s",
2688 + $queue_id
2689 + ));
2690 +
2691 + $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
2692 +
2693 + // Format based on type
2694 + if ($type === 'pdf') {
2695 + return array(
2696 + 'total_pages' => $total,
2697 + 'processed_pages' => $completed + $failed,
2698 + 'failed_pages' => $failed,
2699 + 'percentage' => $percentage,
2700 + 'status' => $is_complete ? 'complete' : 'processing',
2701 + 'last_update' => $last_update_text,
2702 + 'failed_pages_list' => $failed_items,
2703 + 'pdf_url' => $source_url,
2704 + 'queue_id' => $queue_id
2705 + );
2706 + } else {
2707 + return array(
2708 + 'total_urls' => $total,
2709 + 'processed_urls' => $completed + $failed,
2710 + 'failed_urls' => $failed,
2711 + 'percentage' => $percentage,
2712 + 'status' => $is_complete ? 'complete' : 'processing',
2713 + 'last_update' => $last_update_text,
2714 + 'failed_urls_list' => $failed_items,
2715 + 'sitemap_url' => $source_url,
2716 + 'queue_id' => $queue_id
2717 + );
2718 + }
2719 +}
2720 +
2721 +/**
2722 + * Public method to get processing status for both sitemap and PDF queues
2723 + * Used by admin pages to display processing status
2724 + *
2725 + * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
2726 + */
2727 +public function mxchat_get_processing_statuses() {
2728 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2729 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2730 +
2731 + $sitemap_status = false;
2732 + $pdf_status = false;
2733 +
2734 + if ($sitemap_queue_id) {
2735 + $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2736 + }
2737 +
2738 + if ($pdf_queue_id) {
2739 + $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2740 + }
2741 +
2742 + $is_processing =
2743 + ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2744 + ($pdf_status && $pdf_status['status'] === 'processing');
2745 +
2746 + return array(
2747 + 'sitemap_status' => $sitemap_status,
2748 + 'pdf_status' => $pdf_status,
2749 + 'is_processing' => $is_processing
2750 + );
2751 +}
2752 +
2753 +/**
2754 + * AJAX handler to get recent knowledge entries for real-time table updates
2755 + * UPDATED: Now supports both WordPress DB and Pinecone data sources
2756 + */
2757 +public function ajax_mxchat_get_recent_entries() {
2758 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
2759 +
2760 + if (!current_user_can('manage_options')) {
2761 + wp_send_json_error(array('message' => 'Unauthorized'));
2762 + return;
2763 + }
2764 +
2765 + global $wpdb;
2766 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2767 +
2768 + // Get parameters
2769 + $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
2770 + $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
2771 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2772 +
2773 + // Check if Pinecone is enabled for this bot
2774 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
2775 + $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2776 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2777 + $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2778 +
2779 + if ($use_pinecone && $has_pinecone_api) {
2780 + // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
2781 + // Use mxchat_fetch_pinecone_records which returns total_unique_entries
2782 + $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
2783 + $total_count = $records['total'] ?? 0;
2784 +
2785 + // For Pinecone, we don't return individual entries during polling
2786 + // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
2787 + // We just return the updated count. Over 500 vectors that is the live
2788 + // index / namespace vector count climbing during an import (plan dd6e10).
2789 + wp_send_json_success(array(
2790 + 'entries' => array(),
2791 + 'total_count' => absint($total_count),
2792 + 'count_unit' => $records['count_unit'] ?? '',
2793 + 'max_id' => $last_id,
2794 + 'data_source' => 'pinecone'
2795 + ));
2796 + return;
2797 + }
2798 +
2799 + // WORDPRESS DB DATA SOURCE
2800 + // Build query to get entries newer than last_id
2801 + $where_clauses = array('1=1');
2802 + $where_values = array();
2803 +
2804 + if ($last_id > 0) {
2805 + $where_clauses[] = 'id > %d';
2806 + $where_values[] = $last_id;
2807 + }
2808 +
2809 + // Note: WordPress DB table doesn't have bot_id column
2810 + // Multi-bot filtering is handled via Pinecone namespaces
2811 +
2812 + $where_sql = implode(' AND ', $where_clauses);
2813 +
2814 + // Get recent entries
2815 + $query = "SELECT id, article_content, source_url, timestamp
2816 + FROM $table_name
2817 + WHERE $where_sql
2818 + ORDER BY id DESC
2819 + LIMIT %d";
2820 +
2821 + $where_values[] = $limit;
2822 +
2823 + $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
2824 +
2825 + // Get total count of GROUPED entries (by source_url) - matches pagination display
2826 + // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
2827 + $total_count = $wpdb->get_var(
2828 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2829 + (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2830 + );
2831 +
2832 + // Format entries for response
2833 + $formatted_entries = array();
2834 + $preview_length = 150;
2835 + foreach ($entries as $entry) {
2836 + // Parse chunk metadata using the proper chunker method (same as initial page load)
2837 + if (class_exists('MxChat_Chunker')) {
2838 + $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
2839 + $display_content = $chunk_meta['text'];
2840 + $chunk_metadata = $chunk_meta['metadata'];
2841 + } else {
2842 + $display_content = $entry->article_content;
2843 + $chunk_metadata = array();
2844 + }
2845 +
2846 + $content_preview = mb_strlen($display_content) > $preview_length
2847 + ? mb_substr($display_content, 0, $preview_length) . '...'
2848 + : $display_content;
2849 +
2850 + $formatted_entries[] = array(
2851 + 'id' => $entry->id,
2852 + 'preview' => esc_html($content_preview),
2853 + 'full_content' => wp_kses_post(wpautop($display_content)),
2854 + 'content_length' => mb_strlen($display_content),
2855 + 'preview_length' => $preview_length,
2856 + 'source_url' => $entry->source_url,
2857 + 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2858 + 'chunk_metadata' => $chunk_metadata,
2859 + 'bot_id' => $entry->bot_id ?? 'default',
2860 + 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2861 + 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2862 + );
2863 + }
2864 +
2865 + wp_send_json_success(array(
2866 + 'entries' => $formatted_entries,
2867 + 'total_count' => absint($total_count),
2868 + 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
2869 + 'data_source' => 'wordpress'
2870 + ));
2871 +}
2872 +
2873 +/**
2874 + * Get Pinecone total count from stats API
2875 + * Helper function for ajax_mxchat_get_recent_entries
2876 + */
2877 +private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
2878 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2879 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2880 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2881 +
2882 + if (empty($api_key) || empty($host)) {
2883 + return 0;
2884 + }
2885 +
2886 + // Document index (plan 362c31).
2887 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
2888 + return MxChat_Pinecone_Documents::count(MxChat_Pinecone_Documents::cfg_from_options($pinecone_options));
2889 + }
2890 +
2891 + try {
2892 + $stats_url = "https://{$host}/describe_index_stats";
2893 +
2894 + $response = wp_remote_post($stats_url, array(
2895 + 'headers' => array(
2896 + 'Api-Key' => $api_key,
2897 + 'Content-Type' => 'application/json'
2898 + ),
2899 + 'body' => '{}',
2900 + 'timeout' => 10
2901 + ));
2902 +
2903 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2904 + $body = wp_remote_retrieve_body($response);
2905 + $stats_data = json_decode($body, true);
2906 +
2907 + // If namespace is specified, get count from that specific namespace
2908 + if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
2909 + return intval($stats_data['namespaces'][$namespace]['vectorCount']);
2910 + }
2911 +
2912 + // If no namespace specified or namespace not found in response, use total
2913 + return intval($stats_data['totalVectorCount'] ?? 0);
2914 + }
2915 +
2916 + return 0;
2917 +
2918 + } catch (Exception $e) {
2919 + return 0;
2920 + }
2921 +}
2922 +
2923 +/**
2924 + * AJAX handler to refresh Pinecone entries table via AJAX
2925 + * Returns the table HTML for updating the UI without a full page reload
2926 + */
2927 +public function ajax_mxchat_refresh_pinecone_entries() {
2928 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
2929 +
2930 + if (!current_user_can('manage_options')) {
2931 + wp_send_json_error(array('message' => 'Unauthorized'));
2932 + return;
2933 + }
2934 +
2935 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2936 + $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2937 + $per_page = 25;
2938 + $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2939 + $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2940 +
2941 + // Get Pinecone manager and options
2942 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
2943 + if (!$pinecone_manager) {
2944 + wp_send_json_error(array('message' => 'Pinecone manager not available'));
2945 + return;
2946 + }
2947 +
2948 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
2949 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2950 + $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2951 +
2952 + if (!$use_pinecone || empty($pinecone_api_key)) {
2953 + wp_send_json_error(array('message' => 'Pinecone not configured'));
2954 + return;
2955 + }
2956 +
2957 + // Fetch records from Pinecone
2958 + $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
2959 + $prompts = $records['data'] ?? array();
2960 + $total_records = $records['total'] ?? 0;
2961 +
2962 + // Preprocess Pinecone records — set chunk_metadata and display_content
2963 + // (matches admin-knowledge-page.php preprocessing)
2964 + foreach ($prompts as $prompt) {
2965 + if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
2966 + $prompt->chunk_metadata = array(
2967 + 'chunk_index' => intval($prompt->chunk_index),
2968 + 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
2969 + 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
2970 + 'source_url' => $prompt->source_url ?? ''
2971 + );
2972 + $prompt->display_content = $prompt->article_content;
2973 + } else {
2974 + $prompt->chunk_metadata = array();
2975 + $prompt->display_content = $prompt->article_content ?? '';
2976 + }
2977 + }
2978 +
2979 + // Group prompts by source_url
2980 + $grouped_prompts = array();
2981 + foreach ($prompts as $prompt) {
2982 + $source_url = '';
2983 + if (!empty($prompt->chunk_metadata['source_url'])) {
2984 + $source_url = $prompt->chunk_metadata['source_url'];
2985 + } elseif (!empty($prompt->source_url)) {
2986 + $source_url = $prompt->source_url;
2987 + }
2988 +
2989 + if (!empty($source_url)) {
2990 + if (!isset($grouped_prompts[$source_url])) {
2991 + $grouped_prompts[$source_url] = array();
2992 + }
2993 + $grouped_prompts[$source_url][] = $prompt;
2994 + } else {
2995 + $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2996 + }
2997 + }
2998 +
2999 + // Sort each group by chunk_index
3000 + foreach ($grouped_prompts as $source_url => &$group) {
3001 + usort($group, function($a, $b) {
3002 + $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
3003 + $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
3004 + return $index_a - $index_b;
3005 + });
3006 + }
3007 + unset($group);
3008 +
3009 + // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
3010 + ob_start();
3011 + $display_index = 0;
3012 + $current_page = $page;
3013 + $data_source = 'pinecone';
3014 + $current_bot_id = $bot_id;
3015 + $preview_length = 150;
3016 +
3017 + if (empty($grouped_prompts)) {
3018 + echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3019 + esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
3020 + echo '</td></tr>';
3021 + } else {
3022 + foreach ($grouped_prompts as $source_url => $group) {
3023 + $chunk_count = count($group);
3024 + $first_prompt = $group[0];
3025 + $display_index++;
3026 +
3027 + if ($chunk_count > 1) {
3028 + // Multiple chunks - show grouped row with expand button
3029 + $group_id = 'group-' . md5($source_url);
3030 + ?>
3031 + <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3032 + class="mxchat-chunk-group-header"
3033 + data-source="<?php echo esc_attr($data_source); ?>"
3034 + data-group-id="<?php echo esc_attr($group_id); ?>"
3035 + style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
3036 + <td style="padding: 12px 16px; text-align: center;">
3037 + <input type="checkbox"
3038 + class="mxchat-entry-checkbox"
3039 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3040 + data-source="<?php echo esc_attr($data_source); ?>"
3041 + data-source-url="<?php echo esc_attr($source_url); ?>"
3042 + data-is-group="true"
3043 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3044 + </td>
3045 + <td style="padding: 12px 16px; font-size: 13px;">
3046 + <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3047 + </td>
3048 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3049 + <div class="mxchat-chunk-group-info">
3050 + <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3051 + <span class="dashicons dashicons-arrow-right-alt2"></span>
3052 + </button>
3053 + <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3054 + <span class="mxchat-chunk-preview">
3055 + <?php
3056 + $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
3057 + $content_preview = mb_substr($parent_content, 0, 100);
3058 + echo esc_html($content_preview . '...');
3059 + ?>
3060 + </span>
3061 + </div>
3062 + </td>
3063 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3064 + <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3065 + <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3066 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3067 + <?php esc_html_e('View Source', 'mxchat'); ?>
3068 + </a>
3069 + <?php else : ?>
3070 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3071 + <?php endif; ?>
3072 + </td>
3073 + <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3074 + <button type="button"
3075 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3076 + data-source-url="<?php echo esc_attr($source_url); ?>"
3077 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3078 + data-data-source="<?php echo esc_attr($data_source); ?>"
3079 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3080 + data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3081 + title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3082 + <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3083 + </button>
3084 + <button type="button"
3085 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3086 + data-source-url="<?php echo esc_attr($source_url); ?>"
3087 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3088 + data-data-source="<?php echo esc_attr($data_source); ?>"
3089 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3090 + data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3091 + title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3092 + <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3093 + </button>
3094 + <button type="button"
3095 + class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3096 + data-source-url="<?php echo esc_attr($source_url); ?>"
3097 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3098 + data-data-source="<?php echo esc_attr($data_source); ?>"
3099 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3100 + data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3101 + style="color: var(--mxch-error);"
3102 + title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3103 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3104 + </button>
3105 + </td>
3106 + </tr>
3107 + <?php
3108 + // Render hidden chunk rows
3109 + foreach ($group as $chunk_index => $chunk) {
3110 + $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3111 + $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3112 + $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
3113 + $content_preview = mb_strlen($content) > $preview_length
3114 + ? mb_substr($content, 0, $preview_length) . '...'
3115 + : $content;
3116 + ?>
3117 + <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3118 + class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3119 + data-source="<?php echo esc_attr($data_source); ?>"
3120 + style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3121 + <td style="padding: 12px 16px; text-align: center;">
3122 + <!-- Checkbox column placeholder for chunks (managed by group) -->
3123 + </td>
3124 + <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3125 + <!-- Hidden ID column for chunks -->
3126 + </td>
3127 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3128 + <div class="mxchat-accordion-wrapper">
3129 + <div class="mxchat-content-preview">
3130 + <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3131 + <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3132 + </span>
3133 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3134 + <?php if (mb_strlen($content) > $preview_length) : ?>
3135 + <button class="mxchat-expand-toggle" type="button">
3136 + <span class="dashicons dashicons-arrow-down-alt2"></span>
3137 + </button>
3138 + <?php endif; ?>
3139 + </div>
3140 + <div class="mxchat-content-full" style="display: none;">
3141 + <div class="content-view">
3142 + <?php
3143 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3144 + echo '<div dir="rtl" lang="he" class="rtl-content">';
3145 + echo wp_kses_post(wpautop($content));
3146 + echo '</div>';
3147 + } else {
3148 + echo wp_kses_post(wpautop($content));
3149 + }
3150 + ?>
3151 + </div>
3152 + </div>
3153 + </div>
3154 + </td>
3155 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3156 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3157 + </td>
3158 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3159 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3160 + </td>
3161 + </tr>
3162 + <?php
3163 + }
3164 + } else {
3165 + // Single entry - display normally with accordion
3166 + $prompt = $first_prompt;
3167 + $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
3168 + $content_preview = mb_strlen($content) > $preview_length
3169 + ? mb_substr($content, 0, $preview_length) . '...'
3170 + : $content;
3171 + ?>
3172 + <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3173 + data-source="<?php echo esc_attr($data_source); ?>"
3174 + style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
3175 + <td style="padding: 12px 16px; text-align: center;">
3176 + <input type="checkbox"
3177 + class="mxchat-entry-checkbox"
3178 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3179 + data-source="<?php echo esc_attr($data_source); ?>"
3180 + data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3181 + data-is-group="false"
3182 + data-chunk-count="1">
3183 + </td>
3184 + <td style="padding: 12px 16px; font-size: 13px;">
3185 + <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3186 + </td>
3187 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3188 + <div class="mxchat-accordion-wrapper">
3189 + <div class="mxchat-content-preview">
3190 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3191 + <?php if (mb_strlen($content) > $preview_length) : ?>
3192 + <button class="mxchat-expand-toggle" type="button">
3193 + <span class="dashicons dashicons-arrow-down-alt2"></span>
3194 + </button>
3195 + <?php endif; ?>
3196 + </div>
3197 + <div class="mxchat-content-full" style="display: none;">
3198 + <div class="content-view">
3199 + <?php
3200 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3201 + echo '<div dir="rtl" lang="he" class="rtl-content">';
3202 + echo wp_kses_post(wpautop($content));
3203 + echo '</div>';
3204 + } else {
3205 + echo wp_kses_post(wpautop($content));
3206 + }
3207 + ?>
3208 + </div>
3209 + </div>
3210 + </div>
3211 + </td>
3212 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3213 + <?php
3214 + $actual_source = $source_url;
3215 + if (strpos($source_url, '_ungrouped_') === 0) {
3216 + $actual_source = $prompt->source_url ?? '';
3217 + }
3218 + if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3219 + <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3220 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3221 + <?php esc_html_e('View', 'mxchat'); ?>
3222 + </a>
3223 + <?php else : ?>
3224 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3225 + <?php endif; ?>
3226 + </td>
3227 + <td style="padding: 12px 16px; white-space: nowrap;">
3228 + <button type="button"
3229 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3230 + data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3231 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3232 + data-data-source="<?php echo esc_attr($data_source); ?>"
3233 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3234 + data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3235 + title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3236 + <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3237 + </button>
3238 + <button type="button"
3239 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3240 + data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3241 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3242 + data-data-source="<?php echo esc_attr($data_source); ?>"
3243 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3244 + data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3245 + title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3246 + <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3247 + </button>
3248 + <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);">
3249 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3250 + </button>
3251 + </td>
3252 + </tr>
3253 + <?php
3254 + }
3255 + }
3256 + }
3257 + $html = ob_get_clean();
3258 +
3259 + // Generate pagination HTML for Pinecone
3260 + $total_pages = ceil($total_records / $per_page);
3261 + $count_unit = $records['count_unit'] ?? '';
3262 + $cursor_pagination = !empty($records['cursor_pagination']);
3263 + $has_next = !empty($records['has_next']);
3264 + $pagination_html = '';
3265 + if ($cursor_pagination) {
3266 + // Plan dd6e10: cursor path — Previous / Next only, no numbered pages.
3267 + $pagination_html = self::mxchat_cursor_pagination_html($page, $has_next, $search_query, $content_type_filter);
3268 + } elseif ($total_pages > 1) {
3269 + $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) . '">';
3270 +
3271 + // Previous button
3272 + if ($page > 1) {
3273 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3274 + }
3275 +
3276 + // Page numbers
3277 + $start_page = max(1, $page - 2);
3278 + $end_page = min($total_pages, $page + 2);
3279 +
3280 + if ($start_page > 1) {
3281 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3282 + if ($start_page > 2) {
3283 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3284 + }
3285 + }
3286 +
3287 + for ($i = $start_page; $i <= $end_page; $i++) {
3288 + if ($i == $page) {
3289 + $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3290 + } else {
3291 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3292 + }
3293 + }
3294 +
3295 + if ($end_page < $total_pages) {
3296 + if ($end_page < $total_pages - 1) {
3297 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3298 + }
3299 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3300 + }
3301 +
3302 + // Next button
3303 + if ($page < $total_pages) {
3304 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3305 + }
3306 +
3307 + $pagination_html .= '</div>';
3308 + }
3309 +
3310 + wp_send_json_success(array(
3311 + 'html' => $html,
3312 + 'pagination_html' => $pagination_html,
3313 + 'total_count' => $total_records,
3314 + 'count_unit' => $count_unit,
3315 + 'total_pages' => $total_pages,
3316 + 'page' => $page,
3317 + 'per_page' => $per_page,
3318 + 'data_source' => 'pinecone'
3319 + ));
3320 +}
3321 +
3322 +/**
3323 + * Previous / Next markup for the Pinecone cursor path (plan dd6e10).
3324 + *
3325 + * Pinecone's /vectors/list has no random access, so numbered pages would be a
3326 + * fiction; the page number here is only the cursor step, which the manager
3327 + * maps to a stored paginationToken. Used by the page template on first render
3328 + * and by the AJAX paginator, so the two can never drift.
3329 + */
3330 +public static function mxchat_cursor_pagination_html($page, $has_next, $search_query = '', $content_type_filter = '') {
3331 + $page = max(1, absint($page));
3332 + if ($page <= 1 && !$has_next) {
3333 + return '';
3334 + }
3335 + $html = '<div class="mxchat-ajax-pagination mxchat-cursor-pagination" data-current-page="' . esc_attr($page) . '" data-total-pages="" data-search="' . esc_attr($search_query) . '" data-content-type="' . esc_attr($content_type_filter) . '">';
3336 + if ($page > 1) {
3337 + $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3338 + }
3339 + $html .= '<span class="mxchat-page-current">' . esc_html($page) . '</span> ';
3340 + if ($has_next) {
3341 + $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3342 + }
3343 + $html .= '</div>';
3344 + return $html;
3345 +}
3346 +
3347 +/**
3348 + * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
3349 + * Returns paginated entries without requiring a full page reload
3350 + */
3351 +public function ajax_mxchat_paginate_entries() {
3352 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
3353 +
3354 + if (!current_user_can('manage_options')) {
3355 + wp_send_json_error(array('message' => 'Unauthorized'));
3356 + return;
3357 + }
3358 +
3359 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
3360 + $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3361 + $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3362 + $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
3363 + $per_page = 25;
3364 +
3365 + // Check if Pinecone is enabled for this bot
3366 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
3367 + $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
3368 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3369 + $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
3370 +
3371 + if ($use_pinecone && $has_pinecone_api) {
3372 + // Delegate to Pinecone pagination handler (pass search params)
3373 + $_POST['page'] = $page;
3374 + $_POST['search'] = $search_query;
3375 + $_POST['content_type'] = $content_type_filter;
3376 + $this->ajax_mxchat_refresh_pinecone_entries();
3377 + return;
3378 + }
3379 +
3380 + // WordPress DB pagination - MUST match initial page load logic exactly
3381 + global $wpdb;
3382 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3383 + $offset = ($page - 1) * $per_page;
3384 +
3385 + // Build WHERE clause for search and content type filtering
3386 + $where_clauses = array();
3387 + $where_values = array();
3388 +
3389 + if ($search_query) {
3390 + $where_clauses[] = "article_content LIKE %s";
3391 + $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
3392 + }
3393 +
3394 + if ($content_type_filter) {
3395 + switch ($content_type_filter) {
3396 + case 'manual':
3397 + $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
3398 + break;
3399 + case 'pdf':
3400 + $where_clauses[] = "source_url LIKE '%.pdf'";
3401 + break;
3402 + case 'url':
3403 + $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
3404 + break;
3405 + }
3406 + }
3407 +
3408 + $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
3409 +
3410 + // Count grouped entries with filters applied
3411 + if (!empty($where_values)) {
3412 + $count_args = array_merge($where_values, $where_values);
3413 + $count_query = $wpdb->prepare(
3414 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3415 + (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
3416 + ...$count_args
3417 + );
3418 + $total_records = $wpdb->get_var($count_query);
3419 + } else if (!empty($where_sql)) {
3420 + // Content type filter only (no search), no prepared values needed
3421 + $total_records = $wpdb->get_var(
3422 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3423 + (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
3424 + );
3425 + } else {
3426 + // No filters
3427 + $total_records = $wpdb->get_var(
3428 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3429 + (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
3430 + );
3431 + }
3432 + $total_pages = ceil($total_records / $per_page);
3433 +
3434 + // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
3435 + if (!empty($where_values)) {
3436 + $query_args = array_merge($where_values, array($per_page, $offset));
3437 + $urls_query = $wpdb->prepare(
3438 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3439 + {$where_sql}
3440 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3441 + ...$query_args
3442 + );
3443 + } else if (!empty($where_sql)) {
3444 + $urls_query = $wpdb->prepare(
3445 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3446 + {$where_sql}
3447 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3448 + $per_page, $offset
3449 + );
3450 + } else {
3451 + $urls_query = $wpdb->prepare(
3452 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3453 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3454 + $per_page, $offset
3455 + );
3456 + }
3457 + $page_urls = $wpdb->get_results($urls_query);
3458 +
3459 + // Step 2: Build list of source_urls to fetch
3460 + $url_list = array();
3461 + $url_order_map = array();
3462 + $order_index = 0;
3463 + foreach ($page_urls as $url_row) {
3464 + $url = $url_row->source_url;
3465 + $url_list[] = $url;
3466 + $url_order_map[$url] = $order_index++;
3467 + }
3468 +
3469 + // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
3470 + $prompts = array();
3471 + if (!empty($url_list)) {
3472 + $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
3473 + if ($search_query) {
3474 + // Include search filter in the final fetch
3475 + $prompts_query = $wpdb->prepare(
3476 + "SELECT id, article_content, source_url, timestamp, role_restriction
3477 + FROM {$table_name}
3478 + WHERE source_url IN ($placeholders) AND article_content LIKE %s
3479 + ORDER BY timestamp DESC",
3480 + ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
3481 + );
3482 + } else {
3483 + $prompts_query = $wpdb->prepare(
3484 + "SELECT id, article_content, source_url, timestamp, role_restriction
3485 + FROM {$table_name}
3486 + WHERE source_url IN ($placeholders)
3487 + ORDER BY timestamp DESC",
3488 + $url_list
3489 + );
3490 + }
3491 + $prompts = $wpdb->get_results($prompts_query);
3492 + }
3493 +
3494 + // Group prompts by source_url for chunk display
3495 + $grouped_prompts = array();
3496 + foreach ($prompts as $prompt) {
3497 + $source_url = $prompt->source_url ?? '';
3498 +
3499 + // Parse chunk metadata using the proper chunker method (same as initial page load)
3500 + if (class_exists('MxChat_Chunker')) {
3501 + $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
3502 + $prompt->chunk_metadata = $chunk_meta['metadata'];
3503 + $prompt->display_content = $chunk_meta['text'];
3504 + } else {
3505 + $prompt->chunk_metadata = array();
3506 + $prompt->display_content = $prompt->article_content;
3507 + }
3508 +
3509 + if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
3510 + if (!isset($grouped_prompts[$source_url])) {
3511 + $grouped_prompts[$source_url] = array();
3512 + }
3513 + $grouped_prompts[$source_url][] = $prompt;
3514 + } else {
3515 + // Ungrouped entries
3516 + $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
3517 + }
3518 + }
3519 +
3520 + // Sort groups by the original URL order (newest first)
3521 + uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
3522 + $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
3523 + $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
3524 + return $order_a - $order_b;
3525 + });
3526 +
3527 + // Sort each group internally by chunk_index
3528 + foreach ($grouped_prompts as $source_url => &$group) {
3529 + usort($group, function($a, $b) {
3530 + $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
3531 + $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
3532 + return $index_a - $index_b;
3533 + });
3534 + }
3535 + unset($group);
3536 +
3537 + // Build HTML for the table rows
3538 + ob_start();
3539 + $display_index = 0;
3540 + $current_page = $page;
3541 + $data_source = 'wordpress';
3542 + $current_bot_id = $bot_id;
3543 + $preview_length = 150;
3544 +
3545 + if (empty($grouped_prompts)) {
3546 + echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3547 + esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
3548 + echo '</td></tr>';
3549 + } else {
3550 + foreach ($grouped_prompts as $source_url => $group) {
3551 + $chunk_count = count($group);
3552 + $first_prompt = $group[0];
3553 + $display_index++;
3554 +
3555 + if ($chunk_count > 1) {
3556 + // Multiple chunks - show grouped row with expand button
3557 + $group_id = 'group-' . md5($source_url);
3558 + ?>
3559 + <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3560 + class="mxchat-chunk-group-header"
3561 + data-source="<?php echo esc_attr($data_source); ?>"
3562 + data-group-id="<?php echo esc_attr($group_id); ?>"
3563 + style="border-bottom: 1px solid var(--mxch-card-border);">
3564 + <td style="padding: 12px 16px; text-align: center;">
3565 + <input type="checkbox"
3566 + class="mxchat-entry-checkbox"
3567 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3568 + data-source="<?php echo esc_attr($data_source); ?>"
3569 + data-source-url="<?php echo esc_attr($source_url); ?>"
3570 + data-is-group="true"
3571 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3572 + </td>
3573 + <td style="padding: 12px 16px; font-size: 13px;">
3574 + <?php echo esc_html($first_prompt->id); ?>
3575 + </td>
3576 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3577 + <div class="mxchat-chunk-group-info">
3578 + <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3579 + <span class="dashicons dashicons-arrow-right-alt2"></span>
3580 + </button>
3581 + <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3582 + <span class="mxchat-chunk-preview">
3583 + <?php
3584 + $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
3585 + $content_preview = mb_substr($parent_content, 0, 100);
3586 + echo esc_html($content_preview . '...');
3587 + ?>
3588 + </span>
3589 + </div>
3590 + </td>
3591 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3592 + <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3593 + <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3594 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3595 + <?php esc_html_e('View Source', 'mxchat'); ?>
3596 + </a>
3597 + <?php else : ?>
3598 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3599 + <?php endif; ?>
3600 + </td>
3601 + <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3602 + <?php if ($data_source !== 'pinecone') : ?>
3603 + <button type="button"
3604 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3605 + data-source-url="<?php echo esc_attr($source_url); ?>"
3606 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3607 + data-data-source="<?php echo esc_attr($data_source); ?>"
3608 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3609 + data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3610 + title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3611 + <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3612 + </button>
3613 + <?php endif; ?>
3614 + <button type="button"
3615 + class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3616 + data-source-url="<?php echo esc_attr($source_url); ?>"
3617 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3618 + data-data-source="<?php echo esc_attr($data_source); ?>"
3619 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3620 + data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3621 + style="color: var(--mxch-error);"
3622 + title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3623 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3624 + </button>
3625 + </td>
3626 + </tr>
3627 + <?php
3628 + // Render hidden chunk rows
3629 + foreach ($group as $chunk_index => $chunk) {
3630 + $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3631 + $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3632 + $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
3633 + $content_preview = mb_strlen($content) > $preview_length
3634 + ? mb_substr($content, 0, $preview_length) . '...'
3635 + : $content;
3636 + ?>
3637 + <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3638 + class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3639 + data-source="<?php echo esc_attr($data_source); ?>"
3640 + style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3641 + <td style="padding: 12px 16px; text-align: center;">
3642 + <!-- Checkbox column placeholder for chunks (managed by group) -->
3643 + </td>
3644 + <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3645 + <!-- Hidden ID column for chunks -->
3646 + </td>
3647 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3648 + <div class="mxchat-accordion-wrapper">
3649 + <div class="mxchat-content-preview">
3650 + <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3651 + <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3652 + </span>
3653 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3654 + <?php if (mb_strlen($content) > $preview_length) : ?>
3655 + <button class="mxchat-expand-toggle" type="button">
3656 + <span class="dashicons dashicons-arrow-down-alt2"></span>
3657 + </button>
3658 + <?php endif; ?>
3659 + </div>
3660 + <div class="mxchat-content-full" style="display: none;">
3661 + <div class="content-view">
3662 + <?php
3663 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3664 + echo '<div dir="rtl" lang="he" class="rtl-content">';
3665 + echo wp_kses_post(wpautop($content));
3666 + echo '</div>';
3667 + } else {
3668 + echo wp_kses_post(wpautop($content));
3669 + }
3670 + ?>
3671 + </div>
3672 + </div>
3673 + </div>
3674 + </td>
3675 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3676 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3677 + </td>
3678 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3679 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3680 + </td>
3681 + </tr>
3682 + <?php
3683 + }
3684 + } else {
3685 + // Single entry - display normally with accordion
3686 + $prompt = $first_prompt;
3687 + $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
3688 + $content_preview = mb_strlen($content) > $preview_length
3689 + ? mb_substr($content, 0, $preview_length) . '...'
3690 + : $content;
3691 + ?>
3692 + <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3693 + data-source="<?php echo esc_attr($data_source); ?>"
3694 + style="border-bottom: 1px solid var(--mxch-card-border);">
3695 + <td style="padding: 12px 16px; text-align: center;">
3696 + <input type="checkbox"
3697 + class="mxchat-entry-checkbox"
3698 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3699 + data-source="<?php echo esc_attr($data_source); ?>"
3700 + data-source-url="<?php echo esc_attr($source_url); ?>"
3701 + data-is-group="false">
3702 + </td>
3703 + <td style="padding: 12px 16px; font-size: 13px;">
3704 + <?php echo esc_html($prompt->id); ?>
3705 + </td>
3706 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3707 + <div class="mxchat-accordion-wrapper">
3708 + <div class="mxchat-content-preview">
3709 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3710 + <?php if (mb_strlen($content) > $preview_length) : ?>
3711 + <button class="mxchat-expand-toggle" type="button">
3712 + <span class="dashicons dashicons-arrow-down-alt2"></span>
3713 + </button>
3714 + <?php endif; ?>
3715 + </div>
3716 + <div class="mxchat-content-full" style="display: none;">
3717 + <div class="content-view">
3718 + <?php
3719 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3720 + echo '<div dir="rtl" lang="he" class="rtl-content">';
3721 + echo wp_kses_post(wpautop($content));
3722 + echo '</div>';
3723 + } else {
3724 + echo wp_kses_post(wpautop($content));
3725 + }
3726 + ?>
3727 + </div>
3728 + </div>
3729 + </div>
3730 + </td>
3731 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3732 + <?php
3733 + $actual_source = $source_url;
3734 + if (strpos($source_url, '_ungrouped_') === 0) {
3735 + $actual_source = $prompt->source_url ?? '';
3736 + }
3737 + if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3738 + <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3739 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3740 + <?php esc_html_e('View', 'mxchat'); ?>
3741 + </a>
3742 + <?php else : ?>
3743 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3744 + <?php endif; ?>
3745 + </td>
3746 + <td style="padding: 12px 16px; white-space: nowrap;">
3747 + <button type="button"
3748 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3749 + data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3750 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3751 + data-data-source="<?php echo esc_attr($data_source); ?>"
3752 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3753 + data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3754 + title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3755 + <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3756 + </button>
3757 + <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);">
3758 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3759 + </button>
3760 + </td>
3761 + </tr>
3762 + <?php
3763 + }
3764 + }
3765 + }
3766 + $html = ob_get_clean();
3767 +
3768 + // Generate pagination HTML (include search/filter data for subsequent pages)
3769 + $pagination_html = '';
3770 + if ($total_pages > 1) {
3771 + $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) . '">';
3772 +
3773 + // Previous button
3774 + if ($page > 1) {
3775 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3776 + }
3777 +
3778 + // Page numbers
3779 + $start_page = max(1, $page - 2);
3780 + $end_page = min($total_pages, $page + 2);
3781 +
3782 + if ($start_page > 1) {
3783 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3784 + if ($start_page > 2) {
3785 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3786 + }
3787 + }
3788 +
3789 + for ($i = $start_page; $i <= $end_page; $i++) {
3790 + if ($i == $page) {
3791 + $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3792 + } else {
3793 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3794 + }
3795 + }
3796 +
3797 + if ($end_page < $total_pages) {
3798 + if ($end_page < $total_pages - 1) {
3799 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3800 + }
3801 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3802 + }
3803 +
3804 + // Next button
3805 + if ($page < $total_pages) {
3806 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3807 + }
3808 +
3809 + $pagination_html .= '</div>';
3810 + }
3811 +
3812 + wp_send_json_success(array(
3813 + 'html' => $html,
3814 + 'pagination_html' => $pagination_html,
3815 + 'total_count' => $total_records,
3816 + 'total_pages' => $total_pages,
3817 + 'page' => $page,
3818 + 'per_page' => $per_page,
3819 + 'data_source' => 'wordpress'
3820 + ));
3821 +}
3822 +
3823 +/**
3824 + * AJAX handler to detect available sitemaps on the site
3825 + * Optimized for speed - only checks primary sitemap indexes first
3826 + */
3827 +public function ajax_mxchat_detect_sitemaps() {
3828 + check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
3829 +
3830 + if (!current_user_can('manage_options')) {
3831 + wp_send_json_error(array('message' => 'Unauthorized'));
3832 + return;
3833 + }
3834 +
3835 + $site_url = get_site_url();
3836 + $sitemaps = array();
3837 + $found_index = false;
3838 +
3839 + // Only check the main sitemap index files first (much faster)
3840 + // These are the primary entry points that contain sub-sitemaps
3841 + $primary_indexes = array(
3842 + 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
3843 + 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
3844 + 'sitemap.xml' => 'Standard', // Generic/AIOSEO
3845 + );
3846 +
3847 + foreach ($primary_indexes as $path => $source) {
3848 + $url = trailingslashit($site_url) . $path;
3849 +
3850 + $response = wp_remote_head($url, array(
3851 + 'timeout' => 10,
3852 + 'sslverify' => false,
3853 + 'redirection' => 1,
3854 + 'user-agent' => mxchat_ingest_user_agent(),
3855 + ));
3856 +
3857 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3858 + // Found a sitemap index - parse it to get sub-sitemaps
3859 + $sub_sitemaps = $this->parse_sitemap_index($url);
3860 + if (!empty($sub_sitemaps)) {
3861 + $sitemaps[] = array(
3862 + 'url' => $url,
3863 + 'type' => 'index',
3864 + 'source' => $source,
3865 + 'sub_sitemaps' => $sub_sitemaps
3866 + );
3867 + $found_index = true;
3868 + // Found a valid index, no need to check others
3869 + break;
3870 + }
3871 + }
3872 + }
3873 +
3874 + // If no sitemap index found, check for standalone sitemaps
3875 + if (!$found_index) {
3876 + $standalone_sitemaps = array(
3877 + 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3878 + 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3879 + );
3880 +
3881 + foreach ($standalone_sitemaps as $path => $info) {
3882 + $url = trailingslashit($site_url) . $path;
3883 +
3884 + $response = wp_remote_head($url, array(
3885 + 'timeout' => 2,
3886 + 'sslverify' => false
3887 + ));
3888 +
3889 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3890 + $sitemaps[] = array(
3891 + 'url' => $url,
3892 + 'type' => $info['type'],
3893 + 'source' => $info['source'],
3894 + 'url_count' => 0 // Skip URL count for speed
3895 + );
3896 + }
3897 + }
3898 + }
3899 +
3900 + wp_send_json_success(array(
3901 + 'sitemaps' => $sitemaps,
3902 + 'site_url' => $site_url
3903 + ));
3904 +}
3905 +
3906 +/**
3907 + * Parse a sitemap index to get sub-sitemaps
3908 + * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
3909 + */
3910 +private function parse_sitemap_index($url) {
3911 + $sub_sitemaps = array();
3912 +
3913 + $response = wp_remote_get($url, array(
3914 + 'timeout' => 30,
3915 + 'sslverify' => false,
3916 + 'user-agent' => mxchat_ingest_user_agent(),
3917 + 'headers' => array(
3918 + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3919 + ),
3920 + ));
3921 +
3922 + if (is_wp_error($response)) {
3923 + return $sub_sitemaps;
3924 + }
3925 +
3926 + $body = wp_remote_retrieve_body($response);
3927 + if (empty($body)) {
3928 + return $sub_sitemaps;
3929 + }
3930 +
3931 + // Suppress XML errors
3932 + libxml_use_internal_errors(true);
3933 + $xml = simplexml_load_string($body);
3934 + libxml_clear_errors();
3935 +
3936 + if ($xml === false) {
3937 + return $sub_sitemaps;
3938 + }
3939 +
3940 + // Check if it's a sitemap index (contains <sitemap> elements)
3941 + if (isset($xml->sitemap)) {
3942 + foreach ($xml->sitemap as $sitemap) {
3943 + $loc = (string) $sitemap->loc;
3944 + if (!empty($loc)) {
3945 + // Try to determine the type from the URL
3946 + $type = 'content';
3947 + if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
3948 + $type = 'taxonomy';
3949 + } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
3950 + $type = 'author';
3951 + }
3952 +
3953 + // Skip URL count - too slow to fetch for each sitemap
3954 + $sub_sitemaps[] = array(
3955 + 'url' => $loc,
3956 + 'type' => $type,
3957 + 'url_count' => 0, // Don't fetch - takes too long
3958 + 'name' => basename(parse_url($loc, PHP_URL_PATH))
3959 + );
3960 + }
3961 + }
3962 + }
3963 +
3964 + return $sub_sitemaps;
3965 +}
3966 +
3967 +/**
3968 + * Get URL count from a sitemap
3969 + */
3970 +private function get_sitemap_url_count($url) {
3971 + $response = wp_remote_get($url, array(
3972 + 'timeout' => 30,
3973 + 'sslverify' => false,
3974 + 'user-agent' => mxchat_ingest_user_agent(),
3975 + 'headers' => array(
3976 + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3977 + ),
3978 + ));
3979 +
3980 + if (is_wp_error($response)) {
3981 + return 0;
3982 + }
3983 +
3984 + $body = wp_remote_retrieve_body($response);
3985 + if (empty($body)) {
3986 + return 0;
3987 + }
3988 +
3989 + // Count <url> or <loc> elements
3990 + $count = preg_match_all('/<url>/i', $body, $matches);
3991 + return $count ?: 0;
3992 +}
3993 +
3994 +/**
3995 + * Get sitemaps declared in robots.txt
3996 + */
3997 +private function get_sitemaps_from_robots($site_url) {
3998 + $sitemaps = array();
3999 + $robots_url = trailingslashit($site_url) . 'robots.txt';
4000 +
4001 + $response = wp_remote_get($robots_url, array(
4002 + 'timeout' => 15,
4003 + 'sslverify' => false,
4004 + 'user-agent' => mxchat_ingest_user_agent(),
4005 + ));
4006 +
4007 + if (is_wp_error($response)) {
4008 + return $sitemaps;
4009 + }
4010 +
4011 + $body = wp_remote_retrieve_body($response);
4012 + if (empty($body)) {
4013 + return $sitemaps;
4014 + }
4015 +
4016 + // Find Sitemap: declarations
4017 + if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
4018 + foreach ($matches[1] as $sitemap_url) {
4019 + $sitemap_url = trim($sitemap_url);
4020 + if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
4021 + $sitemaps[] = $sitemap_url;
4022 + }
4023 + }
4024 + }
4025 +
4026 + return $sitemaps;
4027 +}
4028 +
4029 +public function mxchat_stop_processing() {
4030 + // Verify permissions
4031 + if (!current_user_can('manage_options')) {
4032 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
4033 + }
4034 +
4035 + // Verify nonce
4036 + check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
4037 +
4038 + global $wpdb;
4039 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4040 +
4041 + // Get active queue IDs
4042 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
4043 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
4044 +
4045 + // Delete all pending items from active queues
4046 + if ($sitemap_queue_id) {
4047 + $wpdb->delete(
4048 + $table_name,
4049 + array(
4050 + 'queue_id' => $sitemap_queue_id,
4051 + 'status' => 'pending'
4052 + ),
4053 + array('%s', '%s')
4054 + );
4055 +
4056 + delete_transient('mxchat_active_queue_sitemap');
4057 + delete_transient('mxchat_last_sitemap_url');
4058 + }
4059 +
4060 + if ($pdf_queue_id) {
4061 + // Get PDF path before deleting
4062 + $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
4063 +
4064 + $wpdb->delete(
4065 + $table_name,
4066 + array(
4067 + 'queue_id' => $pdf_queue_id,
4068 + 'status' => 'pending'
4069 + ),
4070 + array('%s', '%s')
4071 + );
4072 +
4073 + // Delete PDF file
4074 + if ($pdf_path && file_exists($pdf_path)) {
4075 + wp_delete_file($pdf_path);
4076 + }
4077 +
4078 + delete_transient('mxchat_active_queue_pdf');
4079 + delete_transient('mxchat_last_pdf_url');
4080 + }
4081 +
4082 + // Redirect back with a success message
4083 + set_transient('mxchat_admin_notice_success',
4084 + esc_html__('Processing has been stopped successfully.', 'mxchat'),
4085 + 30
4086 + );
4087 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
4088 + exit;
4089 +}
4090 +
4091 +/**
4092 + * Get content list for processing
4093 + */
4094 +public function ajax_mxchat_get_content_list() {
4095 + // Verify the nonce
4096 + check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
4097 +
4098 + if (!current_user_can('manage_options')) {
4099 + wp_send_json_error(__('Unauthorized access', 'mxchat'));
4100 + }
4101 +
4102 + $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
4103 + $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
4104 + $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
4105 + $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
4106 + $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
4107 + $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
4108 +
4109 + // Build query args
4110 + $args = array(
4111 + 'posts_per_page' => $per_page,
4112 + 'paged' => $page,
4113 + 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
4114 + 'orderby' => 'date',
4115 + 'order' => 'DESC',
4116 + );
4117 +
4118 + // Supported media types, and the one-shot WHERE filter that scopes the MIME gate
4119 + // to attachment rows when this query spans several post types (plan 01010a).
4120 + $attachment_mimes = self::mxchat_supported_attachment_mime_types();
4121 + $attachment_where = null;
4122 +
4123 + // Handle post types - IMPROVED VERSION
4124 + if ($post_type !== 'all') {
4125 + $args['post_type'] = $post_type;
4126 +
4127 + // Media is offered by the dropdown above (attachment is a public post type)
4128 + // but could never return a row: attachments are stored with post_status
4129 + // 'inherit' and this query asks for publish/draft/pending, so the list was
4130 + // empty by construction. Force the status attachments actually use, and
4131 + // offer only the file types we can extract text from (plan 01010a).
4132 + if ($post_type === 'attachment') {
4133 + $args['post_status'] = 'inherit';
4134 + $args['post_mime_type'] = $attachment_mimes;
4135 + }
4136 + } else {
4137 + // Get all available post types that might contain content
4138 + $all_post_types = array();
4139 +
4140 + // First get all public post types
4141 + $public_types = get_post_types(array('public' => true), 'names');
4142 + $all_post_types = array_merge($all_post_types, $public_types);
4143 +
4144 + // Add common forum/community post types
4145 + $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
4146 + foreach ($forum_types as $forum_type) {
4147 + if (post_type_exists($forum_type)) {
4148 + $all_post_types[] = $forum_type;
4149 + }
4150 + }
4151 +
4152 + // Add other commonly used post types
4153 + $common_types = array('product', 'job_listing', 'event', 'portfolio');
4154 + foreach ($common_types as $common_type) {
4155 + if (post_type_exists($common_type)) {
4156 + $all_post_types[] = $common_type;
4157 + }
4158 + }
4159 +
4160 + // Remove duplicates and ensure we have at least some post types
4161 + $all_post_types = array_unique($all_post_types);
4162 +
4163 + if (empty($all_post_types)) {
4164 + // Fallback to basic post types
4165 + $all_post_types = array('post', 'page');
4166 + }
4167 +
4168 + // Attachments in the "All Content Types" view (plan 01010a). Two things have
4169 + // to happen at once and neither can ride $args on its own:
4170 + //
4171 + // - 'inherit' has to join the status list, because WP_Query takes ONE status
4172 + // set for the whole query. Only attachments and revisions use it, and
4173 + // revisions are not in $all_post_types, so nothing else widens.
4174 + // - the MIME gate CANNOT be $args['post_mime_type'] here — that clause is
4175 + // applied to every row, and an ordinary post has an empty post_mime_type,
4176 + // so it would empty the entire listing rather than filter the media half.
4177 + // A scoped WHERE is the only shape that filters attachments alone.
4178 + //
4179 + // Media joins this view under the default Published filter and under All
4180 + // Statuses; picking Drafts deliberately drops it, because a file has no draft
4181 + // state and answering a draft filter with published media is a wrong answer.
4182 + $wants_attachments = in_array('attachment', $all_post_types, true)
4183 + && ($post_status === 'publish' || $post_status === 'all');
4184 +
4185 + if ($wants_attachments) {
4186 + $statuses = array_values(array_unique(array_merge((array) $args['post_status'], array('inherit'))));
4187 + $args['post_status'] = $statuses;
4188 +
4189 + global $wpdb;
4190 + $mime_placeholders = implode(', ', array_fill(0, count($attachment_mimes), '%s'));
4191 + $mime_clause = $wpdb->prepare(
4192 + " AND ({$wpdb->posts}.post_type <> 'attachment' OR {$wpdb->posts}.post_mime_type IN ($mime_placeholders))",
4193 + $attachment_mimes
4194 + );
4195 + $attachment_where = function ($where) use ($mime_clause) {
4196 + return $where . $mime_clause;
4197 + };
4198 + add_filter('posts_where', $attachment_where);
4199 + } else {
4200 + $all_post_types = array_values(array_diff($all_post_types, array('attachment')));
4201 + }
4202 +
4203 + $args['post_type'] = $all_post_types;
4204 +
4205 + // Debug logging to see what post types are being queried
4206 + //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
4207 + }
4208 +
4209 + if (!empty($search)) {
4210 + $args['s'] = $search;
4211 + }
4212 +
4213 + // Get processed data from storage
4214 + $processed_data = array();
4215 +
4216 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4217 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4218 +
4219 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4220 + // Get fresh data from Pinecone - no caching
4221 + $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
4222 + } else {
4223 + // WordPress DB checking with better URL matching for all post types
4224 + global $wpdb;
4225 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4226 + $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
4227 +
4228 + // Group items by source_url to count chunks
4229 + $url_chunk_counts = array();
4230 + $url_latest_timestamp = array();
4231 + $url_first_id = array();
4232 +
4233 + if (!empty($processed_items)) {
4234 + foreach ($processed_items as $item) {
4235 + $url = $item->source_url;
4236 + if (empty($url)) continue;
4237 +
4238 + // Count chunks per URL
4239 + if (!isset($url_chunk_counts[$url])) {
4240 + $url_chunk_counts[$url] = 0;
4241 + $url_latest_timestamp[$url] = $item->timestamp;
4242 + $url_first_id[$url] = $item->id;
4243 + }
4244 + $url_chunk_counts[$url]++;
4245 +
4246 + // Track latest timestamp
4247 + if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
4248 + $url_latest_timestamp[$url] = $item->timestamp;
4249 + }
4250 + }
4251 +
4252 + // Now build processed_data with chunk counts
4253 + foreach ($url_chunk_counts as $url => $chunk_count) {
4254 + $post_id = $this->mxchat_url_to_post_id_improved($url);
4255 +
4256 + if ($post_id) {
4257 + $processed_data[$post_id] = array(
4258 + 'db_id' => $url_first_id[$url],
4259 + 'timestamp' => $url_latest_timestamp[$url],
4260 + 'url' => $url,
4261 + 'source' => 'wordpress',
4262 + 'chunk_count' => $chunk_count
4263 + );
4264 + }
4265 + }
4266 + }
4267 + }
4268 +
4269 + // Get processed IDs as a simple array for in_array checks
4270 + $processed_ids = array_keys($processed_data);
4271 +
4272 + // Handle processed/unprocessed filter
4273 + if ($processed_filter === 'processed' && !empty($processed_ids)) {
4274 + $args['post__in'] = $processed_ids;
4275 + } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
4276 + $args['post__not_in'] = $processed_ids;
4277 + }
4278 +
4279 + // Run the query
4280 + $query = new WP_Query($args);
4281 +
4282 + // Scoped to this one query — leaving it attached would MIME-filter every later
4283 + // WP_Query in the request.
4284 + if ($attachment_where) {
4285 + remove_filter('posts_where', $attachment_where);
4286 + }
4287 +
4288 + $content_items = array();
4289 +
4290 + if ($query->have_posts()) {
4291 + while ($query->have_posts()) {
4292 + $query->the_post();
4293 + $id = get_the_ID();
4294 + $post_date = get_the_date();
4295 + $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
4296 + $word_count = str_word_count(strip_tags(get_the_content()));
4297 +
4298 + $is_processed = in_array($id, $processed_ids);
4299 + $processed_date = '';
4300 + $db_record_id = 0;
4301 + $data_source = 'none';
4302 +
4303 + if ($is_processed && isset($processed_data[$id])) {
4304 + $item_data = $processed_data[$id];
4305 + $data_source = $item_data['source'];
4306 +
4307 + if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
4308 + // WordPress DB format
4309 + $timestamp = strtotime($item_data['timestamp']);
4310 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4311 + $db_record_id = $item_data['db_id'];
4312 + } elseif ($data_source === 'pinecone') {
4313 + // Pinecone format
4314 + $processed_date = $item_data['processed_date'];
4315 + $db_record_id = $item_data['db_id'];
4316 + }
4317 + }
4318 +
4319 + // Get chunk count for this item
4320 + $chunk_count = 0;
4321 + if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
4322 + $chunk_count = intval($processed_data[$id]['chunk_count']);
4323 + }
4324 +
4325 + // Media rows describe themselves differently (plan 01010a): the link has
4326 + // to be the FILE, not the attachment page nobody wants; "attachment" and
4327 + // "0 words" tell the user nothing, so the row carries the file type and
4328 + // its size instead. Word count is deliberately NOT computed — that would
4329 + // mean parsing every PDF in the library just to render a list.
4330 + $permalink = get_permalink();
4331 + $type_label = get_post_type();
4332 + $size_label = '';
4333 +
4334 + if (get_post_type() === 'attachment') {
4335 + $file_url = wp_get_attachment_url($id);
4336 + $permalink = $file_url ? $file_url : $permalink;
4337 + $type_label = self::mxchat_attachment_type_label(get_post_mime_type($id));
4338 +
4339 + $file_path = get_attached_file($id);
4340 + $file_size = ($file_path && file_exists($file_path)) ? @filesize($file_path) : false;
4341 + if ($file_size !== false) {
4342 + $size_label = size_format($file_size, 0);
4343 + }
4344 +
4345 + if ($excerpt === '') {
4346 + $excerpt = $file_url ? wp_basename($file_url) : '';
4347 + }
4348 + }
4349 +
4350 + $content_items[] = array(
4351 + 'id' => $id,
4352 + 'title' => get_the_title(),
4353 + 'permalink' => $permalink,
4354 + 'date' => $post_date,
4355 + 'type' => get_post_type(),
4356 + 'type_label' => $type_label,
4357 + 'size_label' => $size_label,
4358 + 'status' => get_post_status(),
4359 + 'excerpt' => $excerpt,
4360 + 'word_count' => $word_count,
4361 + 'already_processed' => $is_processed,
4362 + 'processed_date' => $processed_date,
4363 + 'db_record_id' => $db_record_id,
4364 + 'data_source' => $data_source,
4365 + 'chunk_count' => $chunk_count
4366 + );
4367 + }
4368 + wp_reset_postdata();
4369 + }
4370 +
4371 + $response = array(
4372 + 'items' => $content_items,
4373 + 'total' => $query->found_posts,
4374 + 'total_pages' => $query->max_num_pages,
4375 + 'current_page' => $page,
4376 + 'processed_count' => count($processed_ids)
4377 + );
4378 +
4379 + wp_send_json_success($response);
4380 + exit;
4381 +}
4382 +
4383 +
4384 +/**
4385 + * This function handles various WooCommerce URL formats and permalink structures
4386 + */
4387 +private function mxchat_url_to_post_id_improved($url) {
4388 + // First try the standard WordPress function
4389 + $post_id = url_to_postid($url);
4390 +
4391 + if ($post_id > 0) {
4392 + return $post_id;
4393 + }
4394 +
4395 + // Media entries are stored under the FILE url (…/wp-content/uploads/…), which is
4396 + // not a permalink — url_to_postid() never resolves one, and every slug fallback
4397 + // below excludes 'attachment' explicitly. Without this, an imported media file
4398 + // renders as "Not In Knowledge Base" forever and re-imports as new every time
4399 + // (plan 01010a). attachment_url_to_postid() matches on _wp_attached_file and
4400 + // returns 0 for anything outside the uploads directory, so post permalinks are
4401 + // unaffected.
4402 + $attachment_id = (int) attachment_url_to_postid($url);
4403 + if ($attachment_id > 0) {
4404 + return $attachment_id;
4405 + }
4406 +
4407 + // If that fails, try more aggressive URL matching
4408 + // Remove trailing slashes and query parameters for better matching
4409 + $clean_url = rtrim($url, '/');
4410 + $clean_url = strtok($clean_url, '?'); // Remove query parameters
4411 +
4412 + // Try again with cleaned URL
4413 + $post_id = url_to_postid($clean_url);
4414 + if ($post_id > 0) {
4415 + return $post_id;
4416 + }
4417 +
4418 + // For bbPress forum topics, try extracting slug from URL
4419 + if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
4420 + // Handle bbPress URLs: /forums/topic/topic-name/
4421 + if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
4422 + $topic_slug = $matches[1];
4423 +
4424 + // Look up topic by slug
4425 + $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
4426 + if ($topic) {
4427 + return $topic->ID;
4428 + }
4429 +
4430 + // Alternative method: query by post_name
4431 + global $wpdb;
4432 + $post_id = $wpdb->get_var($wpdb->prepare(
4433 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4434 + $topic_slug
4435 + ));
4436 +
4437 + if ($post_id) {
4438 + return intval($post_id);
4439 + }
4440 + }
4441 +
4442 + // Handle simpler topic URLs: /topic/topic-name/
4443 + if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
4444 + $topic_slug = $matches[1];
4445 +
4446 + global $wpdb;
4447 + $post_id = $wpdb->get_var($wpdb->prepare(
4448 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4449 + $topic_slug
4450 + ));
4451 +
4452 + if ($post_id) {
4453 + return intval($post_id);
4454 + }
4455 + }
4456 + }
4457 +
4458 + // For WooCommerce products
4459 + if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
4460 + // Extract product slug from various URL formats
4461 + $product_slug = '';
4462 +
4463 + // Handle pretty permalinks: /product/product-name/
4464 + if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
4465 + $product_slug = $matches[1];
4466 + }
4467 + // Handle query parameters: ?product=product-name
4468 + elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
4469 + $product_slug = $matches[1];
4470 + }
4471 +
4472 + if (!empty($product_slug)) {
4473 + // Look up product by slug
4474 + $product = get_page_by_path($product_slug, OBJECT, 'product');
4475 + if ($product) {
4476 + return $product->ID;
4477 + }
4478 +
4479 + // Alternative method: query by post_name
4480 + global $wpdb;
4481 + $post_id = $wpdb->get_var($wpdb->prepare(
4482 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
4483 + $product_slug
4484 + ));
4485 +
4486 + if ($post_id) {
4487 + return intval($post_id);
4488 + }
4489 + }
4490 + }
4491 +
4492 + // Generic approach: try to extract slug and match against all post types
4493 + $parsed_url = wp_parse_url($clean_url);
4494 + $path = $parsed_url['path'] ?? '';
4495 +
4496 + if (!empty($path)) {
4497 + // Get the last part of the path as potential slug
4498 + $path_parts = array_filter(explode('/', trim($path, '/')));
4499 + $potential_slug = end($path_parts);
4500 +
4501 + if (!empty($potential_slug)) {
4502 + global $wpdb;
4503 +
4504 + // Try to find any post with this slug
4505 + $post_id = $wpdb->get_var($wpdb->prepare(
4506 + "SELECT ID FROM {$wpdb->posts}
4507 + WHERE post_name = %s
4508 + AND post_status IN ('publish', 'closed', 'private')
4509 + AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
4510 + ORDER BY CASE
4511 + WHEN post_type = 'post' THEN 1
4512 + WHEN post_type = 'page' THEN 2
4513 + WHEN post_type = 'topic' THEN 3
4514 + WHEN post_type = 'product' THEN 4
4515 + ELSE 5
4516 + END
4517 + LIMIT 1",
4518 + $potential_slug
4519 + ));
4520 +
4521 + if ($post_id) {
4522 + return intval($post_id);
4523 + }
4524 + }
4525 + }
4526 +
4527 + // ADDITIONAL: Try direct database lookup by URL variations
4528 + global $wpdb;
4529 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4530 +
4531 + // Try variations of the URL (with/without trailing slash, http/https)
4532 + $url_variations = array(
4533 + $url,
4534 + rtrim($url, '/'),
4535 + $url . '/',
4536 + str_replace('http://', 'https://', $url),
4537 + str_replace('https://', 'http://', $url),
4538 + str_replace('http://', 'https://', rtrim($url, '/')),
4539 + str_replace('https://', 'http://', rtrim($url, '/'))
4540 + );
4541 +
4542 + // Remove duplicates
4543 + $url_variations = array_unique($url_variations);
4544 +
4545 + foreach ($url_variations as $variation) {
4546 + $existing_record = $wpdb->get_row($wpdb->prepare(
4547 + "SELECT id, source_url FROM $table_name WHERE source_url = %s",
4548 + $variation
4549 + ));
4550 +
4551 + if ($existing_record) {
4552 + // Try to get post ID from this stored URL
4553 + $stored_post_id = url_to_postid($existing_record->source_url);
4554 + if ($stored_post_id > 0) {
4555 + return $stored_post_id;
4556 + }
4557 + }
4558 + }
4559 +
4560 + return 0; // No match found
4561 +}
4562 +/**
4563 + * Process selected content via AJAX
4564 + */
4565 +public function ajax_mxchat_process_selected_content() {
4566 + // Basic request validation
4567 + if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
4568 + wp_send_json_error('Invalid nonce');
4569 + exit;
4570 + }
4571 +
4572 + if (!current_user_can('manage_options')) {
4573 + wp_send_json_error('Unauthorized access');
4574 + exit;
4575 + }
4576 +
4577 + // Get post IDs - safely parse the array
4578 + $post_ids = array();
4579 + if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
4580 + foreach ($_POST['post_ids'] as $id) {
4581 + $post_ids[] = absint($id);
4582 + }
4583 + }
4584 +
4585 + if (empty($post_ids)) {
4586 + wp_send_json_error('No content selected');
4587 + exit;
4588 + }
4589 +
4590 + // Get bot_id from request
4591 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
4592 +
4593 + // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields,
4594 + // plan 11720c). The import modal shows a passive status line pointing
4595 + // there; the old per-batch checkbox and its remembered default are gone.
4596 + $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1';
4597 +
4598 + // Process only ONE post at a time to avoid request size issues
4599 + $post_id = reset($post_ids);
4600 + $post = get_post($post_id);
4601 +
4602 + if (!$post) {
4603 + wp_send_json_error('Post not found');
4604 + exit;
4605 + }
4606 +
4607 + // Media-library attachments take their own route (plan 01010a) — different source
4608 + // URL (the file, not the attachment page), different body (an extractor, not
4609 + // post_content), and a file that yields no text is refused with a reason instead
4610 + // of stored empty. Sends its own response and exits.
4611 + if ($post->post_type === 'attachment') {
4612 + $this->mxchat_import_attachment($post, $bot_id);
4613 + }
4614 +
4615 + /**
4616 + * Allow developers to modify post data before processing into the knowledge base.
4617 + * Applied on BOTH content-preparation paths (this manual bulk import and the
4618 + * auto-sync path in mxchat_handle_post_update) with the same signature, so a
4619 + * callback registered once covers every indexing route. Purely additive —
4620 + * zero behaviour change when unhooked.
4621 + *
4622 + * @param WP_Post $post The post about to be indexed.
4623 + * @param string $bot_id Bot context for this import.
4624 + */
4625 + $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
4626 + if (!($post instanceof WP_Post)) {
4627 + $post = get_post($post_id); // defend against a bad callback return
4628 + }
4629 +
4630 + // Assemble the indexable text via the shared post-kind assembler (a3d60c).
4631 + // Bulk import reads raw post fields, has always included product custom tabs,
4632 + // and passes the install-level ACF→PDF option.
4633 + $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
4634 + 'read_display' => false,
4635 + 'extract_acf_pdfs' => $extract_acf_pdfs,
4636 + 'include_product_tabs' => true,
4637 + ));
4638 + $content = $prepared['content'];
4639 + $pdf_extracted_count = $prepared['pdf_extracted_count'];
4640 +
4641 + // Debug logging for WordPress Import content
4642 + //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
4643 + //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
4644 + //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
4645 + //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
4646 +
4647 + // Note: Removed 10,000 char limit - chunking now handles large content properly
4648 +
4649 + // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd)
4650 + $bot_options = $this->get_bot_options($bot_id);
4651 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4652 +
4653 + $preflight = MxChat_Utils::embedding_preflight($options);
4654 + if (!$preflight['ok']) {
4655 + MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)');
4656 + wp_send_json_error($preflight['reason']);
4657 + exit;
4658 + }
4659 + $api_key = $preflight['api_key'];
4660 +
4661 + $source_url = get_permalink($post_id);
4662 + $vector_id = md5($source_url); // Vector ID for Pinecone
4663 +
4664 + // Check for existing content in bot-specific storage
4665 + $is_update = false;
4666 +
4667 + // Get bot-specific Pinecone configuration
4668 + $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4669 + $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
4670 +
4671 + if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
4672 + // Check Pinecone for this bot
4673 + $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
4674 + if (isset($pinecone_data[$post_id])) {
4675 + $is_update = true;
4676 + }
4677 + } else {
4678 + // Check WordPress DB (same as before since it's shared)
4679 + global $wpdb;
4680 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4681 + $existing_record = $wpdb->get_row($wpdb->prepare(
4682 + "SELECT id FROM $table_name WHERE source_url = %s",
4683 + $source_url
4684 + ));
4685 +
4686 + if ($existing_record) {
4687 + $is_update = true;
4688 + }
4689 + }
4690 +
4691 + // UPDATED 2.5.6: Determine content type based on post_type
4692 + $post_type = $post->post_type;
4693 + $content_type = 'content'; // Default fallback
4694 +
4695 + // Map WordPress post types to content types
4696 + switch ($post_type) {
4697 + case 'post':
4698 + $content_type = 'post';
4699 + break;
4700 + case 'page':
4701 + $content_type = 'page';
4702 + break;
4703 + case 'product':
4704 + $content_type = 'product';
4705 + break;
4706 + default:
4707 + // For custom post types, use the post type name
4708 + $content_type = sanitize_key($post_type);
4709 + break;
4710 + }
4711 +
4712 + // Use the centralized utility function with bot_id and content_type
4713 + $result = MxChat_Utils::submit_content_to_db(
4714 + $content,
4715 + $source_url,
4716 + $api_key,
4717 + $vector_id,
4718 + $bot_id,
4719 + $content_type
4720 + );
4721 +
4722 + if (is_wp_error($result)) {
4723 + MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
4724 + wp_send_json_error('Storage failed: ' . $result->get_error_message());
4725 + exit;
4726 + }
4727 +
4728 + // Automatically apply role restriction based on tags
4729 + $this->apply_role_restriction_to_post($post_id, $source_url);
4730 +
4731 + $operation_type = $is_update ? 'update' : 'new';
4732 +
4733 + // Count ACF fields for debugging
4734 + $acf_field_count = $prepared['acf_fields_found'];
4735 +
4736 + // Success response with minimal data
4737 + wp_send_json_success(array(
4738 + 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
4739 + 'post_id' => $post_id,
4740 + 'title' => $post->post_title,
4741 + 'operation_type' => $operation_type,
4742 + 'vector_id' => $vector_id,
4743 + 'acf_fields_found' => $acf_field_count,
4744 + 'pdf_extracted_count' => (int) $pdf_extracted_count,
4745 + 'content_preview' => substr($content, 0, 100) . '...',
4746 + 'bot_id' => $bot_id
4747 + ));
4748 + exit;
4749 +}
4750 +
4751 +private function apply_role_restriction_to_post($post_id, $source_url) {
4752 + $highest_role = 'public';
4753 +
4754 + // Tag-derived restriction. Tags only exist on posts, which is why pages
4755 + // could never be restricted from the Role Restrictions screen (plan 6ab002).
4756 + $mappings = get_option('mxchat_tag_role_mappings', array());
4757 + if (!empty($mappings)) {
4758 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
4759 + if (!empty($post_tags)) {
4760 + foreach ($post_tags as $tag_slug) {
4761 + if (isset($mappings[$tag_slug])) {
4762 + $highest_role = self::mxchat_stricter_role($highest_role, $mappings[$tag_slug]);
4763 + }
4764 + }
4765 + }
4766 + }
4767 +
4768 + // Per-entry restriction set on the post itself, which works for pages and
4769 + // every other public post type. The stricter of the two wins.
4770 + $meta_role = get_post_meta($post_id, '_mxchat_kb_role_restriction', true);
4771 + if (is_string($meta_role) && $meta_role !== '') {
4772 + $highest_role = self::mxchat_stricter_role($highest_role, $meta_role);
4773 + }
4774 +
4775 + // Nothing restricts this entry — leave it public.
4776 + if ($highest_role === 'public' || $highest_role === '') {
4777 + return;
4778 + }
4779 +
4780 + // Update the role restriction in the database
4781 + global $wpdb;
4782 +
4783 + // Check if using Pinecone
4784 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4785 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4786 +
4787 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4788 + // Update Pinecone role restriction
4789 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4790 + $vector_id = md5($source_url);
4791 +
4792 + $wpdb->replace(
4793 + $roles_table,
4794 + array(
4795 + 'vector_id' => $vector_id,
4796 + 'role_restriction' => $highest_role,
4797 + 'updated_at' => current_time('mysql')
4798 + ),
4799 + array('%s', '%s', '%s')
4800 + );
4801 + } else {
4802 + // Update WordPress DB
4803 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4804 +
4805 + $wpdb->update(
4806 + $table_name,
4807 + array('role_restriction' => $highest_role),
4808 + array('source_url' => $source_url),
4809 + array('%s'),
4810 + array('%s')
4811 + );
4812 + }
4813 +
4814 + // The entry's restriction just changed — keep the OpenAI Vector Store
4815 + // mirror consistent: non-public pulls the file (file_search has no
4816 + // per-role filtering), public re-mirrors it (plan 15b5c6).
4817 + if (class_exists('MxChat_Vectorstore_Manager')) {
4818 + MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
4819 + }
4820 +}
4821 +
4822 +public function mxchat_get_public_post_types() {
4823 + // Get all public post types
4824 + $post_types = get_post_types(array('public' => true), 'objects');
4825 + $post_type_options = array();
4826 +
4827 + foreach ($post_types as $post_type) {
4828 + $post_type_options[$post_type->name] = $post_type->label;
4829 + }
4830 +
4831 + // Also include common forum/community post types that might not be marked as public
4832 + $additional_types = array(
4833 + 'topic' => 'Forum Topics (bbPress)',
4834 + 'reply' => 'Forum Replies (bbPress)',
4835 + 'forum' => 'Forums (bbPress)',
4836 + 'wpforo_topic' => 'wpForo Topics',
4837 + 'wpforo_post' => 'wpForo Posts'
4838 + );
4839 +
4840 + foreach ($additional_types as $type_name => $type_label) {
4841 + if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
4842 + $post_type_options[$type_name] = $type_label;
4843 + }
4844 + }
4845 +
4846 + return $post_type_options;
4847 +}
4848 +
4849 +/**
4850 + * Retrieves processed content from Pinecone API
4851 + */
4852 +public function mxchat_get_pinecone_processed_content($pinecone_options) {
4853 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4854 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4855 +
4856 + if (empty($api_key) || empty($host)) {
4857 + return array();
4858 + }
4859 +
4860 + $pinecone_data = array();
4861 +
4862 + try {
4863 + // Always get fresh data from Pinecone
4864 + $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
4865 +
4866 + // Method 2: Final fallback - try stats endpoint (if available)
4867 + if (empty($pinecone_data)) {
4868 + $stats_url = "https://{$host}/describe_index_stats";
4869 +
4870 + $response = wp_remote_post($stats_url, array(
4871 + 'headers' => array(
4872 + 'Api-Key' => $api_key,
4873 + 'Content-Type' => 'application/json'
4874 + ),
4875 + 'body' => json_encode(array()),
4876 + 'timeout' => 30
4877 + ));
4878 +
4879 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4880 + $body = wp_remote_retrieve_body($response);
4881 + $stats_data = json_decode($body, true);
4882 + }
4883 + }
4884 +
4885 + } catch (Exception $e) {
4886 + // Log error but return fresh data only
4887 + }
4888 +
4889 + return $pinecone_data;
4890 +}
4891 +public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
4892 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4893 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4894 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4895 +
4896 + if (empty($api_key) || empty($host) || empty($vector_ids)) {
4897 + return array();
4898 + }
4899 +
4900 + try {
4901 + // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
4902 + // repeated in the query string (ids=a&ids=b — http_build_query would
4903 + // emit ids[0]=a); the old POST here was answered 200-with-an-empty-body,
4904 + // which read as "nothing indexed". Chunked at 100 ids to stay well
4905 + // under the measured HTTP 414 URL-length boundary.
4906 + $vectors = array();
4907 + // Document index (plan 362c31): documents/fetch instead of the GET loop (same id => vector shape).
4908 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
4909 + $vectors = MxChat_Pinecone_Documents::fetch(array_values($vector_ids), MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
4910 + } else
4911 + foreach (array_chunk(array_values($vector_ids), 100) as $chunk) {
4912 + $fetch_query = array();
4913 + foreach ($chunk as $fetch_vid) {
4914 + $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
4915 + }
4916 + if (!empty($namespace)) {
4917 + $fetch_query[] = 'namespace=' . rawurlencode($namespace);
4918 + }
4919 +
4920 + $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
4921 + 'headers' => array(
4922 + 'Api-Key' => $api_key,
4923 + 'accept' => 'application/json'
4924 + ),
4925 + 'timeout' => 30
4926 + ));
4927 +
4928 + if (is_wp_error($response)) {
4929 + error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET failed: ' . $response->get_error_message());
4930 + continue;
4931 + }
4932 +
4933 + if (wp_remote_retrieve_response_code($response) !== 200) {
4934 + error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET returned HTTP ' . wp_remote_retrieve_response_code($response));
4935 + continue;
4936 + }
4937 +
4938 + $data = json_decode(wp_remote_retrieve_body($response), true);
4939 + if (isset($data['vectors']) && is_array($data['vectors'])) {
4940 + $vectors += $data['vectors'];
4941 + }
4942 + }
4943 +
4944 + if (empty($vectors)) {
4945 + return array();
4946 + }
4947 +
4948 + $processed_data = array();
4949 +
4950 + foreach ($vectors as $vector_id => $vector_data) {
4951 + $metadata = $vector_data['metadata'] ?? array();
4952 + $source_url = $metadata['source_url'] ?? '';
4953 +
4954 + if (!empty($source_url)) {
4955 + $post_id = url_to_postid($source_url);
4956 + // A media entry is stored under its FILE url, which url_to_postid() cannot
4957 + // resolve — without this the Pinecone read reports every imported
4958 + // attachment as unprocessed while the WordPress-DB read (which goes
4959 + // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
4960 + if (!$post_id) {
4961 + $post_id = (int) attachment_url_to_postid($source_url);
4962 + }
4963 + if ($post_id) {
4964 + $created_at = $metadata['created_at'] ?? '';
4965 + $processed_date = 'Recently';
4966 +
4967 + if (!empty($created_at)) {
4968 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4969 + if ($timestamp) {
4970 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4971 + }
4972 + }
4973 +
4974 + $processed_data[$post_id] = array(
4975 + 'db_id' => $vector_id,
4976 + 'processed_date' => $processed_date,
4977 + 'url' => $source_url,
4978 + 'source' => 'pinecone',
4979 + 'timestamp' => $timestamp ?? current_time('timestamp')
4980 + );
4981 + }
4982 + }
4983 + }
4984 +
4985 + //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
4986 + return $processed_data;
4987 +
4988 + } catch (Exception $e) {
4989 + //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
4990 + return array();
4991 + }
4992 +}
4993 +
4994 +/**
4995 + * Get embedding dimensions based on the selected model.
4996 + */
4997 +private function mxchat_get_embedding_dimensions() {
4998 + // See MxChat_Pinecone_Manager::mxchat_get_embedding_dimensions() — same answer (plan 418afd).
4999 + $options = get_option('mxchat_options', array());
5000 + $dimension = MxChat_Utils::expected_embedding_dimension($options);
5001 + if (!is_wp_error($dimension) && (int) $dimension > 0) {
5002 + return (int) $dimension;
5003 + }
5004 + return MxChat_Utils::standard_embedding_dimension($options);
5005 +}
5006 +
5007 +/**
5008 + * Scan Pinecone for processed content
5009 + */
5010 +public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
5011 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5012 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5013 + // plan 793b82: this scan was namespace-blind — on a namespaced setup it
5014 + // surveyed the default namespace and reported the wrong content as indexed.
5015 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
5016 +
5017 + if (empty($api_key) || empty($host)) {
5018 + return array();
5019 + }
5020 +
5021 + try {
5022 + // Use multiple random vectors to get better coverage
5023 + $all_matches = array();
5024 + $seen_ids = array();
5025 +
5026 + // Get correct dimensions for the configured embedding model
5027 + $dimensions = $this->mxchat_get_embedding_dimensions();
5028 +
5029 + // Try 3 different random vectors to get better coverage
5030 + for ($i = 0; $i < 3; $i++) {
5031 + $query_url = "https://{$host}/query";
5032 +
5033 + // Generate a random unit vector instead of zeros
5034 + $random_vector = array();
5035 + for ($j = 0; $j < $dimensions; $j++) {
5036 + $random_vector[] = (rand(-1000, 1000) / 1000.0);
5037 + }
5038 +
5039 + // Normalize the vector to unit length
5040 + $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
5041 + if ($magnitude > 0) {
5042 + $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
5043 + }
5044 +
5045 + $query_data = array(
5046 + 'includeMetadata' => true,
5047 + 'includeValues' => false,
5048 + 'topK' => 10000,
5049 + 'vector' => $random_vector
5050 + );
5051 +
5052 + if (!empty($namespace)) {
5053 + $query_data['namespace'] = $namespace;
5054 + }
5055 +
5056 + // Document index (plan 362c31): same random-vector sweep through documents/search.
5057 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
5058 + $doc_hits = MxChat_Pinecone_Documents::search_dense($random_vector, 10000, null, MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
5059 + foreach ((is_wp_error($doc_hits) ? array() : $doc_hits) as $match) {
5060 + $match_id = $match['id'] ?? '';
5061 + if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5062 + $all_matches[] = $match;
5063 + $seen_ids[$match_id] = true;
5064 + }
5065 + }
5066 + continue;
5067 + }
5068 +
5069 + $response = wp_remote_post($query_url, array(
5070 + 'headers' => array(
5071 + 'Api-Key' => $api_key,
5072 + 'Content-Type' => 'application/json'
5073 + ),
5074 + 'body' => json_encode($query_data),
5075 + 'timeout' => 30
5076 + ));
5077 +
5078 + if (is_wp_error($response)) {
5079 + continue;
5080 + }
5081 +
5082 + $response_code = wp_remote_retrieve_response_code($response);
5083 +
5084 + if ($response_code !== 200) {
5085 + continue;
5086 + }
5087 +
5088 + $body = wp_remote_retrieve_body($response);
5089 + $data = json_decode($body, true);
5090 +
5091 + if (isset($data['matches'])) {
5092 + foreach ($data['matches'] as $match) {
5093 + $match_id = $match['id'] ?? '';
5094 + if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5095 + $all_matches[] = $match;
5096 + $seen_ids[$match_id] = true;
5097 + }
5098 + }
5099 + }
5100 + }
5101 +
5102 + // Convert matches to processed data format, grouping by URL to count chunks
5103 + $processed_data = array();
5104 + $url_chunk_counts = array();
5105 +
5106 + foreach ($all_matches as $match) {
5107 + $metadata = $match['metadata'] ?? array();
5108 + $source_url = $metadata['source_url'] ?? '';
5109 + $match_id = $match['id'] ?? '';
5110 +
5111 + if (!empty($source_url) && !empty($match_id)) {
5112 + $post_id = url_to_postid($source_url);
5113 + // A media entry is stored under its FILE url, which url_to_postid() cannot
5114 + // resolve — without this the Pinecone read reports every imported
5115 + // attachment as unprocessed while the WordPress-DB read (which goes
5116 + // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
5117 + if (!$post_id) {
5118 + $post_id = (int) attachment_url_to_postid($source_url);
5119 + }
5120 + if ($post_id) {
5121 + // Count chunks per post_id
5122 + if (!isset($url_chunk_counts[$post_id])) {
5123 + $url_chunk_counts[$post_id] = 0;
5124 + }
5125 + $url_chunk_counts[$post_id]++;
5126 +
5127 + $created_at = $metadata['created_at'] ?? '';
5128 + $processed_date = 'Recently';
5129 +
5130 + if (!empty($created_at)) {
5131 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
5132 + if ($timestamp) {
5133 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
5134 + }
5135 + }
5136 +
5137 + // Only store if not already set, or update with newer timestamp
5138 + if (!isset($processed_data[$post_id]) ||
5139 + ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
5140 + $processed_data[$post_id] = array(
5141 + 'db_id' => $match_id,
5142 + 'processed_date' => $processed_date,
5143 + 'url' => $source_url,
5144 + 'source' => 'pinecone',
5145 + 'timestamp' => $timestamp ?? current_time('timestamp')
5146 + );
5147 + }
5148 + }
5149 + }
5150 + }
5151 +
5152 + // Add chunk counts to processed data
5153 + foreach ($url_chunk_counts as $post_id => $chunk_count) {
5154 + if (isset($processed_data[$post_id])) {
5155 + $processed_data[$post_id]['chunk_count'] = $chunk_count;
5156 + }
5157 + }
5158 +
5159 + return $processed_data;
5160 +
5161 + } catch (Exception $e) {
5162 + return array();
5163 + }
5164 +}
5165 +/**
5166 + * Generate embeddings from input text for MXChat with bot support
5167 + */
5168 +private function mxchat_generate_embedding($text, $bot_id = 'default') {
5169 + // Enable detailed logging for debugging
5170 + //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
5171 + //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
5172 +
5173 + // Get bot-specific options
5174 + $bot_options = $this->get_bot_options($bot_id);
5175 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
5176 +
5177 + // Opt-in: when the custom provider is selected for embeddings, index through
5178 + // the same custom endpoint the query path uses so stored vectors and query
5179 + // vectors share a model. Returns the vector array on success, or an error
5180 + // string on failure (this function's existing failure contract).
5181 + if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5182 + if (!class_exists('MxChat_Utils')) {
5183 + require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
5184 + }
5185 + return MxChat_Utils::generate_embedding_custom($text, $options);
5186 + }
5187 +
5188 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5189 + //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
5190 +
5191 + // Determine provider and endpoint
5192 + if (strpos($selected_model, 'voyage') === 0) {
5193 + $api_key = $options['voyage_api_key'] ?? '';
5194 + $endpoint = 'https://api.voyageai.com/v1/embeddings';
5195 + $provider_name = 'Voyage AI';
5196 + //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
5197 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5198 + $api_key = $options['gemini_api_key'] ?? '';
5199 + $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
5200 + $provider_name = 'Google Gemini';
5201 + //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
5202 + } else {
5203 + $api_key = $options['api_key'] ?? '';
5204 + $endpoint = 'https://api.openai.com/v1/embeddings';
5205 + $provider_name = 'OpenAI';
5206 + //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
5207 + }
5208 +
5209 + //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
5210 +
5211 + if (empty($api_key)) {
5212 + $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
5213 + //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
5214 + return $error_message;
5215 + }
5216 +
5217 + // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
5218 + $estimated_tokens = ceil(str_word_count($text) / 0.75);
5219 + //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
5220 +
5221 + if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
5222 + //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
5223 + // Consider truncating text here
5224 + }
5225 +
5226 + // Prepare request body based on provider
5227 + if (strpos($selected_model, 'gemini-embedding') === 0) {
5228 + // Gemini API format
5229 + $request_body = array(
5230 + 'model' => 'models/' . $selected_model,
5231 + 'content' => array(
5232 + 'parts' => array(
5233 + array('text' => $text)
5234 + )
5235 + )
5236 + );
5237 +
5238 + // Set output dimensionality to 1536 for consistency with other models
5239 + $request_body['outputDimensionality'] = 1536;
5240 + } else {
5241 + // OpenAI/Voyage API format
5242 + $request_body = array(
5243 + 'model' => $selected_model,
5244 + 'input' => $text
5245 + );
5246 +
5247 + // Add output_dimension for voyage-3-large model
5248 + if ($selected_model === 'voyage-3-large') {
5249 + $request_body['output_dimension'] = 2048;
5250 + }
5251 + }
5252 +
5253 + //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
5254 +
5255 + // Prepare headers based on provider
5256 + if (strpos($selected_model, 'gemini-embedding') === 0) {
5257 + // Gemini uses API key as query parameter
5258 + $endpoint .= '?key=' . $api_key;
5259 + $headers = array(
5260 + 'Content-Type' => 'application/json'
5261 + );
5262 + } else {
5263 + // OpenAI/Voyage use Bearer token
5264 + $headers = array(
5265 + 'Authorization' => 'Bearer ' . $api_key,
5266 + 'Content-Type' => 'application/json'
5267 + );
5268 + }
5269 +
5270 + // Make API request
5271 + //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
5272 + $response = wp_remote_post($endpoint, array(
5273 + 'body' => wp_json_encode($request_body),
5274 + 'headers' => $headers,
5275 + 'timeout' => 60 // Increased timeout for large inputs
5276 + ));
5277 +
5278 + // Handle wp_remote_post errors
5279 + if (is_wp_error($response)) {
5280 + $error_message = $response->get_error_message();
5281 + //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
5282 + return 'Connection error: ' . $error_message;
5283 + }
5284 +
5285 + // Get and check HTTP response code
5286 + $http_code = wp_remote_retrieve_response_code($response);
5287 + //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
5288 +
5289 + if ($http_code !== 200) {
5290 + $error_body = wp_remote_retrieve_body($response);
5291 + //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
5292 +
5293 + // Try to parse error for more details
5294 + $error_json = json_decode($error_body, true);
5295 + if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
5296 + $error_type = $error_json['error']['type'] ?? 'unknown';
5297 + $error_message = $error_json['error']['message'] ?? 'No message';
5298 + //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
5299 + //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
5300 +
5301 + // Keep the provider's own diagnostic — a restricted-key 401 names the
5302 + // exact missing scope, and replacing it with "check your API key" sent
5303 + // a customer to regenerate two keys (plan 46b596). Same shape as
5304 + // MxChat_Utils::embedding_failure_error() so both ingestion paths read
5305 + // identically. Key never appears in provider messages, but scrub anyway.
5306 + if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') {
5307 + if (is_string($api_key) && $api_key !== '') {
5308 + $error_message = str_replace($api_key, '[redacted]', $error_message);
5309 + }
5310 + $error_message = sprintf(
5311 + 'Embedding failed (%s, HTTP %d): %s',
5312 + $selected_model,
5313 + $http_code,
5314 + substr($error_message, 0, 300)
5315 + );
5316 + }
5317 +
5318 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5319 + return $error_message;
5320 + }
5321 +
5322 + $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
5323 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5324 + return $error_message;
5325 + }
5326 +
5327 + // Parse response body
5328 + $response_body = wp_remote_retrieve_body($response);
5329 + //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
5330 +
5331 + $response_data = json_decode($response_body, true);
5332 +
5333 + if (json_last_error() !== JSON_ERROR_NONE) {
5334 + $error = json_last_error_msg();
5335 + //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
5336 + //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
5337 + return "Failed to parse API response: $error";
5338 + }
5339 +
5340 + // Handle different response formats based on provider
5341 + if (strpos($selected_model, 'gemini-embedding') === 0) {
5342 + // Gemini API response format
5343 + if (isset($response_data['embedding']['values'])) {
5344 + $embedding_dimensions = count($response_data['embedding']['values']);
5345 + //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
5346 +
5347 + // Check if embedding dimensions are as expected (should be 1536)
5348 + if ($embedding_dimensions !== 1536) {
5349 + //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
5350 + }
5351 +
5352 + return $response_data['embedding']['values'];
5353 + } else {
5354 + //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
5355 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5356 +
5357 + if (isset($response_data['error'])) {
5358 + $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
5359 + //error_log('[MXCHAT-EMBED] ' . $error_message);
5360 + return $error_message;
5361 + }
5362 +
5363 + $error_message = "Invalid Gemini API response format: No embedding found";
5364 + //error_log('[MXCHAT-EMBED] ' . $error_message);
5365 + return $error_message;
5366 + }
5367 + } else {
5368 + // OpenAI/Voyage API response format
5369 + if (isset($response_data['data'][0]['embedding'])) {
5370 + $embedding_dimensions = count($response_data['data'][0]['embedding']);
5371 + //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
5372 +
5373 + // Check if embedding dimensions are as expected
5374 + if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
5375 + ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
5376 + //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
5377 + }
5378 +
5379 + return $response_data['data'][0]['embedding'];
5380 + } else {
5381 + //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
5382 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5383 +
5384 + if (isset($response_data['error'])) {
5385 + $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
5386 + //error_log('[MXCHAT-EMBED] ' . $error_message);
5387 + return $error_message;
5388 + }
5389 +
5390 + $error_message = "Invalid API response format: No embedding found";
5391 + //error_log('[MXCHAT-EMBED] ' . $error_message);
5392 + return $error_message;
5393 + }
5394 + }
5395 +}
5396 +
5397 +/**
5398 + * Get bot-specific options for multi-bot functionality
5399 + * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
5400 + */
5401 +private function get_bot_options($bot_id = 'default') {
5402 + //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
5403 +
5404 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5405 + //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
5406 + return array();
5407 + }
5408 +
5409 + $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
5410 +
5411 + if (!empty($bot_options)) {
5412 + //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
5413 + if (isset($bot_options['similarity_threshold'])) {
5414 + //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
5415 + }
5416 + }
5417 +
5418 + return is_array($bot_options) ? $bot_options : array();
5419 +}
5420 +
5421 +/**
5422 + * Get bot-specific Pinecone configuration
5423 + * Used in the knowledge retrieval functions
5424 + */
5425 +// Also add debugging to your get_bot_pinecone_config function
5426 +private function get_bot_pinecone_config($bot_id = 'default') {
5427 + //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
5428 +
5429 + // If default bot or multi-bot add-on not active, use default Pinecone config
5430 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5431 + //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
5432 + $addon_options = get_option('mxchat_pinecone_addon_options', array());
5433 + $config = array(
5434 + 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
5435 + 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
5436 + 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
5437 + 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
5438 + );
5439 + //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
5440 + return $config;
5441 + }
5442 +
5443 + //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
5444 +
5445 + // Hook for multi-bot add-on to provide bot-specific Pinecone config
5446 + $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
5447 +
5448 + if (!empty($bot_pinecone_config)) {
5449 + //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
5450 + //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
5451 + //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
5452 + //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
5453 + } else {
5454 + //error_log("MXCHAT DEBUG: Filter returned empty config!");
5455 + }
5456 +
5457 + return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
5458 +}
5459 +
5460 +
5461 +public function mxchat_ajax_dismiss_completed_status() {
5462 + try {
5463 + // Verify the request
5464 + check_ajax_referer('mxchat_status_nonce', 'nonce');
5465 +
5466 + if (!current_user_can('manage_options')) {
5467 + wp_send_json_error('Unauthorized access');
5468 + exit;
5469 + }
5470 +
5471 + $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
5472 +
5473 + if ($card_type === 'pdf') {
5474 + // Clear PDF status
5475 + $pdf_url = get_transient('mxchat_last_pdf_url');
5476 + if ($pdf_url) {
5477 + delete_transient('mxchat_pdf_status_' . md5($pdf_url));
5478 + delete_transient('mxchat_last_pdf_url');
5479 + }
5480 + } elseif ($card_type === 'sitemap') {
5481 + // Clear sitemap status
5482 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
5483 + if ($sitemap_url) {
5484 + delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
5485 + delete_transient('mxchat_last_sitemap_url');
5486 + }
5487 + }
5488 +
5489 + wp_send_json_success(array('message' => 'Status dismissed successfully'));
5490 +
5491 + } catch (Exception $e) {
5492 + wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
5493 + }
5494 +}
5495 +
5496 +/**
5497 + * Render completed status cards on page load
5498 + * This ensures completed processing status persists through page refreshes
5499 + */
5500 +public function mxchat_render_completed_status_cards() {
5501 + $output = '';
5502 +
5503 + // Check for completed PDF status
5504 + $pdf_url = get_transient('mxchat_last_pdf_url');
5505 + if ($pdf_url) {
5506 + $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
5507 + if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
5508 + $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
5509 + }
5510 + }
5511 +
5512 + // Check for completed sitemap status
5513 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
5514 + if ($sitemap_url) {
5515 + $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
5516 + if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
5517 + $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
5518 + }
5519 + }
5520 +
5521 + return $output;
5522 +}
5523 +
5524 +/**
5525 + * Render PDF status card HTML
5526 + */
5527 +private function mxchat_render_pdf_status_card($status, $pdf_url) {
5528 + $html = '<div class="mxchat-status-card" data-card-type="pdf">';
5529 + $html .= '<div class="mxchat-status-header">';
5530 + $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
5531 +
5532 + // Add dismiss button for completed status
5533 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
5534 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5535 + }
5536 +
5537 + // Process Batch button for processing status
5538 + if ($status['status'] === 'processing') {
5539 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
5540 + data-process-type="pdf"
5541 + data-url="' . esc_attr($pdf_url) . '">
5542 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5543 + }
5544 +
5545 + // Add status badges
5546 + if ($status['status'] === 'error') {
5547 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5548 + } elseif ($status['status'] === 'complete') {
5549 + if ($status['failed_pages'] > 0) {
5550 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5551 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
5552 + } else {
5553 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5554 + }
5555 + }
5556 +
5557 + $html .= '</div>'; // End header
5558 +
5559 + // Progress bar
5560 + $html .= '<div class="mxchat-progress-bar">';
5561 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5562 + $html .= '</div>';
5563 +
5564 + // Status details
5565 + $html .= '<div class="mxchat-status-details">';
5566 + $html .= '<p>' . sprintf(
5567 + esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
5568 + $status['processed_pages'],
5569 + $status['total_pages'],
5570 + $status['percentage']
5571 + ) . '</p>';
5572 +
5573 + // Show failed pages count if any
5574 + if ($status['failed_pages'] > 0) {
5575 + $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
5576 + }
5577 +
5578 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5579 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5580 +
5581 + // Add completion summary if available AND it's an array
5582 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5583 + $summary = $status['completion_summary'];
5584 + $html .= '<div class="mxchat-completion-summary">';
5585 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5586 + $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
5587 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
5588 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
5589 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5590 + $html .= '</div>';
5591 + }
5592 +
5593 + // Add failed pages list if any AND it's an array
5594 + if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
5595 + $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
5596 + }
5597 +
5598 + // Add error message if any
5599 + if (isset($status['error']) && !empty($status['error'])) {
5600 + $html .= '<div class="mxchat-error-notice">';
5601 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5602 + $html .= '</div>';
5603 + }
5604 +
5605 + $html .= '</div>'; // End details
5606 + $html .= '</div>'; // End card
5607 +
5608 + return $html;
5609 +}
5610 +/**
5611 + * Render sitemap status card HTML
5612 + */
5613 +private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
5614 + $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
5615 + $html .= '<div class="mxchat-status-header">';
5616 + $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
5617 +
5618 + // Add dismiss button for completed status
5619 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
5620 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5621 + }
5622 +
5623 + // Process Batch button for processing status
5624 + if ($status['status'] === 'processing') {
5625 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
5626 + data-process-type="sitemap"
5627 + data-url="' . esc_attr($sitemap_url) . '">
5628 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5629 + }
5630 +
5631 + // Add status badges
5632 + if ($status['status'] === 'error') {
5633 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5634 + } elseif ($status['status'] === 'complete') {
5635 + if ($status['failed_urls'] > 0) {
5636 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5637 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
5638 + } else {
5639 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5640 + }
5641 + }
5642 +
5643 + $html .= '</div>'; // End header
5644 +
5645 + // Progress bar
5646 + $html .= '<div class="mxchat-progress-bar">';
5647 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5648 + $html .= '</div>';
5649 +
5650 + // Status details
5651 + $html .= '<div class="mxchat-status-details">';
5652 + $html .= '<p>' . sprintf(
5653 + esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
5654 + $status['processed_urls'],
5655 + $status['total_urls'],
5656 + $status['percentage']
5657 + ) . '</p>';
5658 +
5659 + // Show failed URLs count if any
5660 + if ($status['failed_urls'] > 0) {
5661 + $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
5662 + }
5663 +
5664 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5665 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5666 +
5667 + // Add completion summary if available AND it's an array
5668 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5669 + $summary = $status['completion_summary'];
5670 + $html .= '<div class="mxchat-completion-summary">';
5671 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5672 + $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
5673 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
5674 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
5675 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5676 + $html .= '</div>';
5677 + }
5678 +
5679 + // Add error messages if any (but not the failed URLs list)
5680 + if (!empty($status['error']) || !empty($status['last_error'])) {
5681 + $html .= '<div class="mxchat-error-notice">';
5682 +
5683 + if (!empty($status['error'])) {
5684 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5685 + }
5686 +
5687 + if (!empty($status['last_error'])) {
5688 + $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
5689 + }
5690 +
5691 + $html .= '</div>';
5692 + }
5693 +
5694 + $html .= '</div>'; // End details
5695 + $html .= '</div>'; // End card
5696 +
5697 + return $html;
5698 +}
5699 +
5700 +
5701 +/**
5702 + * Render failed pages list
5703 + */
5704 +private function mxchat_render_failed_pages_list($failed_pages_list) {
5705 + // Validate that $failed_pages_list is an array and not empty
5706 + if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
5707 + return '';
5708 + }
5709 +
5710 + $html = '<div class="mxchat-error-notice">';
5711 + $html .= '<div class="mxchat-failed-pages-container">';
5712 + $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
5713 + $html .= '<details>';
5714 + $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
5715 + $html .= '<div class="mxchat-failed-pages-list">';
5716 +
5717 + // Create table for failed pages
5718 + $html .= '<table class="widefat striped">';
5719 + $html .= '<thead><tr>';
5720 + $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
5721 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5722 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5723 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5724 + $html .= '</tr></thead><tbody>';
5725 +
5726 + // Sort failed pages by most recent
5727 + $sorted_failed_pages = $failed_pages_list;
5728 + usort($sorted_failed_pages, function($a, $b) {
5729 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5730 + });
5731 +
5732 + foreach ($sorted_failed_pages as $item) {
5733 + // Ensure $item is an array before accessing its elements
5734 + if (!is_array($item)) {
5735 + continue;
5736 + }
5737 +
5738 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5739 + $html .= '<tr>';
5740 + $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
5741 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5742 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5743 + $html .= '<td>' . esc_html($time_ago) . '</td>';
5744 + $html .= '</tr>';
5745 + }
5746 +
5747 + $html .= '</tbody></table>';
5748 + $html .= '</div></details></div></div>';
5749 +
5750 + return $html;
5751 +}
5752 +
5753 +/**
5754 + * Render failed URLs list
5755 + */
5756 +private function mxchat_render_failed_urls_list($failed_urls_list) {
5757 + // Validate that $failed_urls_list is an array and not empty
5758 + if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
5759 + return '';
5760 + }
5761 +
5762 + $html = '<div class="mxchat-failed-urls-container">';
5763 + $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
5764 + $html .= '<details>';
5765 + $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
5766 + $html .= '<div class="mxchat-failed-urls-list">';
5767 +
5768 + // Create table for failed URLs
5769 + $html .= '<table class="widefat striped">';
5770 + $html .= '<thead><tr>';
5771 + $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
5772 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5773 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5774 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5775 + $html .= '</tr></thead><tbody>';
5776 +
5777 + // Sort failed URLs by most recent
5778 + $sorted_failed_urls = $failed_urls_list;
5779 + usort($sorted_failed_urls, function($a, $b) {
5780 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5781 + });
5782 +
5783 + // Show up to 50 failed URLs
5784 + $display_urls = array_slice($sorted_failed_urls, 0, 50);
5785 +
5786 + foreach ($display_urls as $item) {
5787 + // Ensure $item is an array before accessing its elements
5788 + if (!is_array($item)) {
5789 + continue;
5790 + }
5791 +
5792 + $url = $item['url'] ?? '';
5793 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5794 +
5795 + // Truncate URL for display
5796 + $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
5797 +
5798 + $html .= '<tr>';
5799 + $html .= '<td style="word-break: break-all;">';
5800 + if (!empty($url)) {
5801 + $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
5802 + } else {
5803 + $html .= esc_html__('Unknown URL', 'mxchat');
5804 + }
5805 + $html .= '</td>';
5806 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5807 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5808 + $html .= '<td>' . esc_html($time_ago) . '</td>';
5809 + $html .= '</tr>';
5810 + }
5811 +
5812 + $html .= '</tbody></table>';
5813 +
5814 + if (count($failed_urls_list) > 50) {
5815 + $html .= '<div class="mxchat-failed-urls-more">+ ' .
5816 + (count($failed_urls_list) - 50) .
5817 + ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
5818 + }
5819 +
5820 + $html .= '</div></details></div>';
5821 +
5822 + return $html;
5823 +}
5824 +
5825 +/**
5826 + * Get all ACF fields for a specific post, excluding any fields the user has disabled
5827 + */
5828 +public function mxchat_get_acf_fields_for_post($post_id) {
5829 + if (!function_exists('get_field_objects')) {
5830 + return array();
5831 + }
5832 +
5833 + // Field OBJECTS, not get_fields(): exclusion matches on the field KEY
5834 + // (unique per field) rather than the name (shared across groups — plan
5835 + // 30e81f). ACF's own get_fields() is implemented as get_field_objects()
5836 + // reduced to name => value, so the un-excluded reduction below is the
5837 + // identical shape and order the previous get_fields() call produced.
5838 + $field_objects = get_field_objects($post_id);
5839 + if (!$field_objects || !is_array($field_objects)) {
5840 + return array();
5841 + }
5842 +
5843 + $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5844 + if (!is_array($excluded_fields)) {
5845 + $excluded_fields = array();
5846 + }
5847 +
5848 + $fields = array();
5849 + foreach ($field_objects as $field_name => $field_object) {
5850 + if (!empty($excluded_fields)) {
5851 + $field_key = isset($field_object['key']) ? $field_object['key'] : '';
5852 + // Legacy name entries stay honored: a stored name whose group was
5853 + // inactive at migration time still excludes every field wearing it.
5854 + if (in_array($field_key, $excluded_fields, true) || in_array($field_name, $excluded_fields, true)) {
5855 + continue;
5856 + }
5857 + }
5858 + $fields[$field_name] = isset($field_object['value']) ? $field_object['value'] : null;
5859 + }
5860 +
5861 + return $fields;
5862 +}
5863 +
5864 +/**
5865 + * Get all registered ACF field groups and their fields for the settings UI
5866 + */
5867 +public function mxchat_get_all_acf_fields() {
5868 + if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5869 + return array();
5870 + }
5871 +
5872 + // Keyed by GROUP KEY, not title (titles are not unique), and each field
5873 + // entry carries its ACF field key — the unique identifier every toggle,
5874 + // save, and index-time exclusion now runs on (plans 30e81f / bf57e0).
5875 + $all_fields = array();
5876 + $field_groups = acf_get_field_groups();
5877 +
5878 + if (!empty($field_groups)) {
5879 + foreach ($field_groups as $group) {
5880 + $group_fields = acf_get_fields($group['key']);
5881 + if (!empty($group_fields)) {
5882 + $entry = array(
5883 + 'title' => $group['title'],
5884 + 'fields' => array(),
5885 + );
5886 + foreach ($group_fields as $field) {
5887 + $entry['fields'][] = array(
5888 + 'key' => $field['key'],
5889 + 'name' => $field['name'],
5890 + 'label' => $field['label'],
5891 + 'type' => $field['type']
5892 + );
5893 + }
5894 + $all_fields[$group['key']] = $entry;
5895 + }
5896 + }
5897 + }
5898 +
5899 + return $all_fields;
5900 +}
5901 +
5902 +/**
5903 + * Get whitelisted custom post meta for a given post
5904 + * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
5905 + */
5906 +public function mxchat_get_whitelisted_post_meta($post_id) {
5907 + $whitelist = get_option('mxchat_custom_meta_whitelist', '');
5908 +
5909 + if (empty($whitelist)) {
5910 + return array();
5911 + }
5912 +
5913 + // Parse the whitelist - one meta key per line
5914 + $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
5915 +
5916 + if (empty($meta_keys)) {
5917 + return array();
5918 + }
5919 +
5920 + $result = array();
5921 +
5922 + foreach ($meta_keys as $key) {
5923 + // Skip empty keys
5924 + if (empty($key)) {
5925 + continue;
5926 + }
5927 +
5928 + $value = get_post_meta($post_id, $key, true);
5929 +
5930 + // Only include non-empty string values
5931 + if (!empty($value) && is_string($value)) {
5932 + $result[$key] = $value;
5933 + } elseif (!empty($value) && is_array($value)) {
5934 + // Handle array values by joining them
5935 + $flat_value = $this->mxchat_flatten_meta_array($value);
5936 + if (!empty($flat_value)) {
5937 + $result[$key] = $flat_value;
5938 + }
5939 + }
5940 + }
5941 +
5942 + return $result;
5943 +}
5944 +
5945 +/**
5946 + * Flatten array meta values into a readable string
5947 + */
5948 +private function mxchat_flatten_meta_array($array, $depth = 0) {
5949 + if ($depth > 3) {
5950 + return ''; // Prevent infinite recursion
5951 + }
5952 +
5953 + $parts = array();
5954 +
5955 + foreach ($array as $key => $value) {
5956 + if (is_string($value) && !empty($value)) {
5957 + $parts[] = $value;
5958 + } elseif (is_array($value)) {
5959 + $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
5960 + if (!empty($nested)) {
5961 + $parts[] = $nested;
5962 + }
5963 + }
5964 + }
5965 +
5966 + return implode(', ', $parts);
5967 +}
5968 +
5969 +/**
5970 + * Format ACF field values for content extraction
5971 + */
5972 +public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
5973 + if (empty($value)) {
5974 + return '';
5975 + }
5976 +
5977 + // Handle WP_Post objects first (THIS IS THE KEY FIX)
5978 + if ($value instanceof WP_Post) {
5979 + return $value->post_title ?: '';
5980 + }
5981 +
5982 + // Handle other WP objects
5983 + if (is_object($value)) {
5984 + if (isset($value->post_title)) {
5985 + return $value->post_title;
5986 + } elseif (isset($value->display_name)) {
5987 + return $value->display_name;
5988 + } elseif (isset($value->name)) {
5989 + return $value->name;
5990 + } elseif (method_exists($value, '__toString')) {
5991 + try {
5992 + return (string) $value;
5993 + } catch (Exception $e) {
5994 + return '';
5995 + }
5996 + }
5997 + // For any other objects, return empty string
5998 + return '';
5999 + }
6000 +
6001 + // Handle different ACF field types
6002 + if (is_array($value)) {
6003 + // Check if it's an image/file field
6004 + if (isset($value['url'])) {
6005 + // Image field - return alt text, title, or caption
6006 + if (!empty($value['alt'])) {
6007 + return $value['alt'];
6008 + } elseif (!empty($value['title'])) {
6009 + return $value['title'];
6010 + } elseif (!empty($value['caption'])) {
6011 + return $value['caption'];
6012 + } else {
6013 + return ''; // Don't include just the URL
6014 + }
6015 + }
6016 +
6017 + // Check if it's a post object or relationship field
6018 + if (isset($value['post_title'])) {
6019 + return $value['post_title'];
6020 + }
6021 +
6022 + // Check if it's a user field
6023 + if (isset($value['display_name'])) {
6024 + return $value['display_name'];
6025 + }
6026 +
6027 + // Check if it's a taxonomy term
6028 + if (isset($value['name']) && isset($value['taxonomy'])) {
6029 + return $value['name'];
6030 + }
6031 +
6032 + // Check if it's a select field with label
6033 + if (isset($value['label'])) {
6034 + return $value['label'];
6035 + }
6036 +
6037 + // Check for repeater field or flexible content
6038 + if (is_numeric(key($value))) {
6039 + $sub_values = array();
6040 + foreach ($value as $sub_item) {
6041 + if (is_array($sub_item)) {
6042 + // For repeater/flexible content, extract text values
6043 + $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
6044 + if (!empty($sub_text)) {
6045 + $sub_values[] = $sub_text;
6046 + }
6047 + } elseif ($sub_item instanceof WP_Post) {
6048 + // Handle WP_Post objects in arrays
6049 + $sub_values[] = $sub_item->post_title ?: '';
6050 + } else {
6051 + $sub_values[] = (string) $sub_item;
6052 + }
6053 + }
6054 + return implode(', ', array_filter($sub_values));
6055 + }
6056 +
6057 + // For other arrays, try to extract meaningful text
6058 + $text_values = array();
6059 + foreach ($value as $key => $val) {
6060 + if (is_string($val) && !empty(trim($val))) {
6061 + $text_values[] = trim($val);
6062 + } elseif ($val instanceof WP_Post) {
6063 + // Handle WP_Post objects in associative arrays
6064 + $text_values[] = $val->post_title ?: '';
6065 + } elseif (is_array($val) && isset($val['post_title'])) {
6066 + $text_values[] = $val['post_title'];
6067 + } elseif (is_array($val) && isset($val['name'])) {
6068 + $text_values[] = $val['name'];
6069 + }
6070 + }
6071 +
6072 + return implode(', ', array_filter($text_values));
6073 + }
6074 +
6075 + // Handle boolean values
6076 + if (is_bool($value)) {
6077 + return $value ? 'Yes' : 'No';
6078 + }
6079 +
6080 + // Handle numeric values
6081 + if (is_numeric($value)) {
6082 + return (string) $value;
6083 + }
6084 +
6085 + // Handle string values
6086 + if (is_string($value)) {
6087 + return trim($value);
6088 + }
6089 +
6090 + // For anything else that we can't handle, return empty string
6091 + // This prevents the "Object could not be converted to string" error
6092 + return '';
6093 +}
6094 +
6095 +/**
6096 + * Extract text from complex ACF array structures
6097 + */
6098 +private function mxchat_extract_text_from_acf_array($array) {
6099 + if (!is_array($array)) {
6100 + return '';
6101 + }
6102 +
6103 + $text_parts = array();
6104 +
6105 + foreach ($array as $key => $value) {
6106 + if (is_string($value) && !empty(trim($value))) {
6107 + // Skip keys that are likely to be IDs or technical values
6108 + if (!is_numeric($value) || strlen($value) > 10) {
6109 + $text_parts[] = trim($value);
6110 + }
6111 + } elseif ($value instanceof WP_Post) {
6112 + // Handle WP_Post objects
6113 + $text_parts[] = $value->post_title ?: '';
6114 + } elseif (is_array($value)) {
6115 + if (isset($value['post_title'])) {
6116 + $text_parts[] = $value['post_title'];
6117 + } elseif (isset($value['name'])) {
6118 + $text_parts[] = $value['name'];
6119 + } elseif (isset($value['label'])) {
6120 + $text_parts[] = $value['label'];
6121 + }
6122 + } elseif (is_object($value)) {
6123 + // Handle other objects safely
6124 + if (isset($value->post_title)) {
6125 + $text_parts[] = $value->post_title;
6126 + } elseif (isset($value->name)) {
6127 + $text_parts[] = $value->name;
6128 + } elseif (isset($value->display_name)) {
6129 + $text_parts[] = $value->display_name;
6130 + }
6131 + }
6132 + }
6133 +
6134 + return implode(', ', array_filter($text_parts));
6135 +}
6136 +
6137 +/**
6138 + * Walk an ACF field value tree and collect attachment IDs for any value that
6139 + * resolves to a PDF in the WordPress media library. Handles the three shapes
6140 + * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
6141 + * plain URL string), and recurses through repeater/group/flexible content.
6142 + *
6143 + * @param mixed $value The ACF field value (any depth)
6144 + * @param array $out Accumulator (passed by reference) for attachment IDs
6145 + * @param int $depth Recursion guard
6146 + */
6147 +private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
6148 + if ($depth > 6) {
6149 + return; // prevent runaway recursion on circular/very-deep structures
6150 + }
6151 +
6152 + if (empty($value)) {
6153 + return;
6154 + }
6155 +
6156 + // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
6157 + if (is_array($value)) {
6158 + // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
6159 + $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
6160 + if ($looks_like_attachment) {
6161 + $att_id = 0;
6162 + if (!empty($value['ID']) && is_numeric($value['ID'])) {
6163 + $att_id = (int) $value['ID'];
6164 + } elseif (!empty($value['id']) && is_numeric($value['id'])) {
6165 + $att_id = (int) $value['id'];
6166 + } elseif (!empty($value['url']) && is_string($value['url'])) {
6167 + $att_id = (int) attachment_url_to_postid($value['url']);
6168 + }
6169 +
6170 + $is_pdf = false;
6171 + if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
6172 + $is_pdf = true;
6173 + } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
6174 + $is_pdf = true;
6175 + } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
6176 + $is_pdf = true;
6177 + } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
6178 + $is_pdf = true;
6179 + }
6180 +
6181 + if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
6182 + $out[] = $att_id;
6183 + }
6184 + // An array node that represents one attachment doesn't contain other
6185 + // attachments inside it — done with this branch.
6186 + return;
6187 + }
6188 +
6189 + // Recurse: repeater rows, flexible-content layouts, groups, etc.
6190 + foreach ($value as $sub) {
6191 + $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
6192 + }
6193 + return;
6194 + }
6195 +
6196 + // Plain numeric attachment ID (ACF File field set to "Return: ID")
6197 + if (is_numeric($value)) {
6198 + $att_id = (int) $value;
6199 + if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6200 + $out[] = $att_id;
6201 + }
6202 + return;
6203 + }
6204 +
6205 + // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
6206 + if (is_string($value)) {
6207 + $trimmed = trim($value);
6208 + if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
6209 + $att_id = (int) attachment_url_to_postid($trimmed);
6210 + if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6211 + $out[] = $att_id;
6212 + }
6213 + }
6214 + return;
6215 + }
6216 +}
6217 +
6218 +/**
6219 + * Heuristic: does this URL/string look like a PDF reference?
6220 + * Tolerates query strings and fragments (#page=2).
6221 + */
6222 +private function mxchat_url_looks_like_pdf($url) {
6223 + if (!is_string($url) || $url === '') {
6224 + return false;
6225 + }
6226 + // Strip query + fragment before checking extension
6227 + $path = preg_replace('/[?#].*$/', '', $url);
6228 + return (bool) preg_match('/\.pdf$/i', $path);
6229 +}
6230 +
6231 +/**
6232 + * Extract text from a PDF attachment by ID using the bundled Smalot parser.
6233 + * Reads the file directly from disk via get_attached_file (no HTTP fetch).
6234 + * Result is cached on the attachment as post_meta keyed by file mtime so we
6235 + * only parse the same PDF once unless the file changes on disk.
6236 + *
6237 + * @param int $attachment_id
6238 + * @return string Extracted plain text, or '' on failure.
6239 + */
6240 +private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
6241 + $attachment_id = (int) $attachment_id;
6242 + if ($attachment_id <= 0) {
6243 + return '';
6244 + }
6245 + if (get_post_mime_type($attachment_id) !== 'application/pdf') {
6246 + return '';
6247 + }
6248 +
6249 + $pdf_path = get_attached_file($attachment_id);
6250 + if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
6251 + return '';
6252 + }
6253 +
6254 + // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
6255 + // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
6256 + $default_max_bytes = 25 * 1024 * 1024;
6257 + $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
6258 + if ($max_bytes > 0) {
6259 + $file_size = @filesize($pdf_path);
6260 + if ($file_size !== false && $file_size > $max_bytes) {
6261 + error_log(sprintf(
6262 + '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
6263 + $attachment_id,
6264 + basename($pdf_path),
6265 + $file_size,
6266 + $max_bytes
6267 + ));
6268 + return '';
6269 + }
6270 + }
6271 +
6272 + $mtime = @filemtime($pdf_path);
6273 + $cache_meta_key = '_mxchat_acf_pdf_text_v1';
6274 + $cached = get_post_meta($attachment_id, $cache_meta_key, true);
6275 + if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
6276 + return (string) $cached['text'];
6277 + }
6278 +
6279 + $text = '';
6280 + try {
6281 + if (function_exists('mxchat_load_pdf_parser')) {
6282 + mxchat_load_pdf_parser();
6283 + }
6284 + if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
6285 + return '';
6286 + }
6287 + $parser = new \Smalot\PdfParser\Parser();
6288 + $pdf = $parser->parseFile($pdf_path);
6289 + $pages = $pdf->getPages();
6290 + $page_texts = array();
6291 + $acf_page_num = 0;
6292 + foreach ($pages as $page) {
6293 + $acf_page_num++;
6294 + $page_text = '';
6295 + try {
6296 + $page_text = $page->getText();
6297 + } catch (\Exception $e) {
6298 + $page_text = '';
6299 + }
6300 + if (!empty($page_text)) {
6301 + $page_text = MxChat_Utils::normalize_pdf_rtl($page_text, 'acf_pdf attachment ' . $attachment_id . ' page ' . $acf_page_num);
6302 + $page_texts[] = $page_text;
6303 + }
6304 + }
6305 + $text = trim(implode("\n\n", $page_texts));
6306 + } catch (\Exception $e) {
6307 + error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
6308 + return '';
6309 + } catch (\Throwable $e) {
6310 + error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
6311 + return '';
6312 + }
6313 +
6314 + // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
6315 + // The chunker downstream will still split this into multiple vectors.
6316 + $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
6317 + if ($max_len > 0 && strlen($text) > $max_len) {
6318 + $text = substr($text, 0, $max_len);
6319 + }
6320 +
6321 + update_post_meta($attachment_id, $cache_meta_key, array(
6322 + 'mtime' => (int) $mtime,
6323 + 'text' => $text,
6324 + ));
6325 +
6326 + return $text;
6327 +}
6328 +
6329 +/**
6330 + * The media-library MIME types the knowledge base can actually read (plan 01010a).
6331 + *
6332 + * The importer's post-type dropdown is built from get_post_types(['public' => true]),
6333 + * and 'attachment' is public — so Media has always been OFFERED as a source. What it
6334 + * could never do is return anything, and the fix is deliberately narrower than the
6335 + * offer: only files one of the THREE extractors already shipping in this plugin can
6336 + * turn into text belong on that list. Images, audio and video are not listed at all
6337 + * rather than listed and greyed out, because "nothing to import here" is a clearer
6338 + * answer than a row you cannot select.
6339 + *
6340 + * text/markdown has no entry in WordPress's default wp_get_mime_types(), so a .md
6341 + * file is only ever in the library on a site that added the mapping itself; it stays
6342 + * on the list for those sites and costs nothing on the rest.
6343 + *
6344 + * @return array MIME type strings.
6345 + */
6346 +public static function mxchat_supported_attachment_mime_types() {
6347 + /**
6348 + * Filter the attachment MIME types offered by the WordPress Content importer.
6349 + *
6350 + * Adding a type here makes it listable and importable — mxchat_extract_attachment_text()
6351 + * reads anything text/* as plain text, so a text-shaped MIME works out of the box.
6352 + * A binary format needs its own extractor.
6353 + *
6354 + * @param array $mime_types Supported MIME types.
6355 + */
6356 + return (array) apply_filters('mxchat_kb_attachment_mime_types', array(
6357 + 'application/pdf',
6358 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
6359 + 'text/plain',
6360 + 'text/markdown',
6361 + ));
6362 +}
6363 +
6364 +/**
6365 + * Short human label for a supported attachment MIME type ("PDF", "Word", ...).
6366 + * Used in the importer list, where "attachment" as a type and "0 words" as a size
6367 + * would tell the user nothing about what they are about to import.
6368 + */
6369 +public static function mxchat_attachment_type_label($mime_type) {
6370 + $labels = array(
6371 + 'application/pdf' => __('PDF', 'mxchat'),
6372 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => __('Word', 'mxchat'),
6373 + 'text/plain' => __('Text', 'mxchat'),
6374 + 'text/markdown' => __('Markdown', 'mxchat'),
6375 + );
6376 +
6377 + return isset($labels[$mime_type]) ? $labels[$mime_type] : __('Document', 'mxchat');
6378 +}
6379 +
6380 +/**
6381 + * Extract indexable text from a media-library attachment (plan 01010a).
6382 + *
6383 + * Wiring, not parsing: every extractor here already ships and is already used
6384 + * elsewhere — the Smalot path via mxchat_extract_pdf_text_by_attachment_id() (ACF
6385 + * PDFs), MXChat_Word_Handler::extract_docx_text() and the plain-text read (both from
6386 + * Document Upload, which is also where the character-estimated page cap comes from).
6387 + * No new parser was added for this.
6388 + *
6389 + * Returns a reason instead of an empty string on failure so the caller can say WHY a
6390 + * file was skipped; an attachment that yields no text must never be stored as an
6391 + * empty entry.
6392 + *
6393 + * @param int $attachment_id
6394 + * @return array{text: string, reason: string} reason is '' on success.
6395 + */
6396 +private function mxchat_extract_attachment_text($attachment_id) {
6397 + $attachment_id = (int) $attachment_id;
6398 + $mime_type = get_post_mime_type($attachment_id);
6399 +
6400 + if (!in_array($mime_type, self::mxchat_supported_attachment_mime_types(), true)) {
6401 + return array(
6402 + 'text' => '',
6403 + 'reason' => __('Unsupported file type — only PDF, Word, text and Markdown files can be imported.', 'mxchat'),
6404 + );
6405 + }
6406 +
6407 + // PDF: the shared extractor owns its own size cap (25 MB raw, filterable), its
6408 + // 50,000-character output cap, the RTL normaliser and the mtime-keyed cache, so
6409 + // re-importing the same PDF does not re-parse it.
6410 + if ($mime_type === 'application/pdf') {
6411 + $text = $this->mxchat_extract_pdf_text_by_attachment_id($attachment_id);
6412 + $text = trim((string) $text);
6413 +
6414 + if ($text === '') {
6415 + return array(
6416 + 'text' => '',
6417 + 'reason' => __('No extractable text — the PDF is empty, scanned images only, or larger than the size limit.', 'mxchat'),
6418 + );
6419 + }
6420 +
6421 + return array('text' => $text, 'reason' => '');
6422 + }
6423 +
6424 + $file_path = get_attached_file($attachment_id);
6425 + if (empty($file_path) || !file_exists($file_path) || !is_readable($file_path)) {
6426 + return array(
6427 + 'text' => '',
6428 + 'reason' => __('The file could not be read from disk.', 'mxchat'),
6429 + );
6430 + }
6431 +
6432 + if ($mime_type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
6433 + $text = MXChat_Word_Handler::extract_docx_text($file_path);
6434 + if ($text === false) {
6435 + return array(
6436 + 'text' => '',
6437 + 'reason' => __('The Word file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
6438 + );
6439 + }
6440 + $text = trim((string) $text);
6441 + } else {
6442 + // .txt / .md read as-is, the same way Document Upload reads them —
6443 + // Markdown deliberately keeps its syntax, because headings are useful
6444 + // retrieval signal.
6445 + $text = (string) file_get_contents($file_path);
6446 + $text = wp_check_invalid_utf8($text);
6447 + $text = trim($text);
6448 + }
6449 +
6450 + if ($text === '') {
6451 + return array(
6452 + 'text' => '',
6453 + 'reason' => __('The file contains no readable text.', 'mxchat'),
6454 + );
6455 + }
6456 +
6457 + // Same cap Document Upload applies to the same three formats: the pdf_max_pages
6458 + // setting, estimated by CHARACTERS (~2500/page) because the .docx cleaner
6459 + // collapses newlines and a paragraph count reads 1 for any Word file. Importing
6460 + // is synchronous, so an unbounded document risks a timeout.
6461 + $options = get_option('mxchat_options', array());
6462 + $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
6463 + $estimated_pages = (int) ceil(strlen($text) / 2500);
6464 + if ($max_pages > 0 && $estimated_pages > $max_pages) {
6465 + return array(
6466 + 'text' => '',
6467 + 'reason' => sprintf(
6468 + /* translators: 1: estimated page count, 2: configured page limit */
6469 + __('Too large (about %1$d pages; the limit is %2$d). Split the file, or raise the PDF max pages setting.', 'mxchat'),
6470 + $estimated_pages,
6471 + $max_pages
6472 + ),
6473 + );
6474 + }
6475 +
6476 + return array('text' => $text, 'reason' => '');
6477 +}
6478 +
6479 +/**
6480 + * Assemble the stored text for an attachment: title first, then the extracted body.
6481 + * Mirrors the post assembler's shape (title on its own line, blank line, content) so
6482 + * a media entry retrieves on its filename/title the same way a post does.
6483 + */
6484 +private function mxchat_build_attachment_content($post, $extracted_text) {
6485 + $title = $this->mxchat_decode_entities_for_indexing($post->post_title);
6486 + $parts = array($title);
6487 +
6488 + // The media modal's Caption and Description fields are real, editable metadata
6489 + // and are frequently the only human-written context a file has.
6490 + $caption = trim(wp_strip_all_tags((string) $post->post_excerpt));
6491 + if ($caption !== '') {
6492 + $parts[] = $this->mxchat_decode_entities_for_indexing($caption);
6493 + }
6494 +
6495 + $description = trim(wp_strip_all_tags((string) $post->post_content));
6496 + if ($description !== '') {
6497 + $parts[] = $this->mxchat_decode_entities_for_indexing($description);
6498 + }
6499 +
6500 + $parts[] = $extracted_text;
6501 +
6502 + return implode("\n\n", array_filter($parts, function ($part) {
6503 + return $part !== '';
6504 + }));
6505 +}
6506 +
6507 +/**
6508 + * Import one media-library attachment into the knowledge base — the attachment
6509 + * branch of ajax_mxchat_process_selected_content (plan 01010a).
6510 + *
6511 + * Separate from the post path rather than folded into it because almost every step
6512 + * differs: the source URL is the FILE url (get_permalink() on an attachment returns
6513 + * its attachment page, which is not what anyone means by "where this document
6514 + * lives"), the body comes from an extractor instead of post_content, and a file that
6515 + * yields no text has to be refused with a reason rather than stored empty.
6516 + *
6517 + * Sends its own JSON response and exits, exactly like its caller.
6518 + */
6519 +private function mxchat_import_attachment($post, $bot_id) {
6520 + $post_id = (int) $post->ID;
6521 +
6522 + $extracted = $this->mxchat_extract_attachment_text($post_id);
6523 + if ($extracted['text'] === '') {
6524 + wp_send_json_error(sprintf(
6525 + /* translators: 1: attachment title, 2: reason the file was skipped */
6526 + __('Skipped "%1$s": %2$s', 'mxchat'),
6527 + $post->post_title,
6528 + $extracted['reason']
6529 + ));
6530 + exit;
6531 + }
6532 +
6533 + $bot_options = $this->get_bot_options($bot_id);
6534 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6535 +
6536 + $preflight = MxChat_Utils::embedding_preflight($options);
6537 + if (!$preflight['ok']) {
6538 + MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (attachment import)');
6539 + wp_send_json_error($preflight['reason']);
6540 + exit;
6541 + }
6542 +
6543 + $source_url = wp_get_attachment_url($post_id);
6544 + if (empty($source_url)) {
6545 + wp_send_json_error(__('The file has no URL — it may be missing from the uploads folder.', 'mxchat'));
6546 + exit;
6547 + }
6548 +
6549 + $vector_id = md5($source_url);
6550 + $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6551 +
6552 + // "Already imported" read, same two-backend split the post path uses.
6553 + $is_update = false;
6554 + $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6555 + $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
6556 +
6557 + if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
6558 + $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
6559 + if (isset($pinecone_data[$post_id])) {
6560 + $is_update = true;
6561 + }
6562 + } else {
6563 + global $wpdb;
6564 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6565 + $existing_record = $wpdb->get_row($wpdb->prepare(
6566 + "SELECT id FROM $table_name WHERE source_url = %s",
6567 + $source_url
6568 + ));
6569 + if ($existing_record) {
6570 + $is_update = true;
6571 + }
6572 + }
6573 +
6574 + // content_type 'attachment' — NOT 'document'. The Knowledge screen's type filter
6575 + // is built from public post types, so 'attachment' is exactly what its "Media"
6576 + // option filters on; 'document' (Document Upload's label) would file these under
6577 + // an option that dropdown does not offer.
6578 + $result = MxChat_Utils::submit_content_to_db(
6579 + $content,
6580 + $source_url,
6581 + $preflight['api_key'],
6582 + $vector_id,
6583 + $bot_id,
6584 + 'attachment'
6585 + );
6586 +
6587 + if (is_wp_error($result)) {
6588 + MxChat_Admin::mxchat_log_debug('storage_error', 'Attachment storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
6589 + wp_send_json_error('Storage failed: ' . $result->get_error_message());
6590 + exit;
6591 + }
6592 +
6593 + // Attachments carry no tags, so the tag half of this is always a no-op — the
6594 + // per-entry dropdown and the MxChat meta box on the attachment's own edit screen
6595 + // are what restrict a media entry (plan 6ab002).
6596 + $this->apply_role_restriction_to_post($post_id, $source_url);
6597 +
6598 + $this->mxchat_remember_attachment_signature($post_id, $source_url, $content);
6599 +
6600 + wp_send_json_success(array(
6601 + 'message' => $is_update ? 'Content updated successfully' : 'Content processed successfully',
6602 + 'post_id' => $post_id,
6603 + 'title' => $post->post_title,
6604 + 'operation_type' => $is_update ? 'update' : 'new',
6605 + 'vector_id' => $vector_id,
6606 + 'acf_fields_found' => 0,
6607 + 'pdf_extracted_count' => (get_post_mime_type($post_id) === 'application/pdf') ? 1 : 0,
6608 + 'content_preview' => substr($content, 0, 100) . '...',
6609 + 'bot_id' => $bot_id
6610 + ));
6611 + exit;
6612 +}
6613 +
6614 +/**
6615 + * Is media auto-sync switched on? The toggle is the generic per-post-type one the
6616 + * Advanced Custom Post Sync Settings list already renders for Media (attachment is a
6617 + * public post type), so there is no new setting and no new UI for it.
6618 + */
6619 +private function mxchat_attachment_auto_sync_enabled() {
6620 + return get_option('mxchat_auto_sync_attachment') === '1';
6621 +}
6622 +
6623 +/**
6624 + * Record what we last indexed for an attachment: the URL it was stored under and a
6625 + * hash of the stored text.
6626 + *
6627 + * This exists because 'edit_attachment' is noisy — it fires on every metadata touch,
6628 + * including ones that change nothing we index (alt text, regenerating thumbnails).
6629 + * Without a signature, each of those would pay for a fresh embedding call. The stored
6630 + * URL is also what lets a file REPLACEMENT clean up after itself: the old URL's
6631 + * vectors are deleted before the new ones are written, instead of being orphaned.
6632 + */
6633 +private function mxchat_remember_attachment_signature($attachment_id, $source_url, $content) {
6634 + update_post_meta((int) $attachment_id, '_mxchat_kb_attachment_sig', array(
6635 + 'url' => (string) $source_url,
6636 + 'hash' => md5((string) $content),
6637 + ));
6638 +}
6639 +
6640 +/**
6641 + * Auto-sync an attachment on upload or edit (add_attachment / edit_attachment).
6642 + *
6643 + * Silent by design, like the post auto-sync path: a media upload is not a knowledge
6644 + * base action from the user's point of view, so a file we cannot read is skipped
6645 + * without an admin notice. The manual importer is where a skip gets a visible reason.
6646 + */
6647 +public function mxchat_handle_attachment_change($attachment_id) {
6648 + $attachment_id = (int) $attachment_id;
6649 +
6650 + if (!$this->mxchat_attachment_auto_sync_enabled()) {
6651 + return;
6652 + }
6653 +
6654 + $post = get_post($attachment_id);
6655 + if (!$post || $post->post_type !== 'attachment') {
6656 + return;
6657 + }
6658 +
6659 + if (!in_array(get_post_mime_type($attachment_id), self::mxchat_supported_attachment_mime_types(), true)) {
6660 + return;
6661 + }
6662 +
6663 + $source_url = wp_get_attachment_url($attachment_id);
6664 + if (empty($source_url)) {
6665 + return;
6666 + }
6667 +
6668 + $extracted = $this->mxchat_extract_attachment_text($attachment_id);
6669 + if ($extracted['text'] === '') {
6670 + return;
6671 + }
6672 +
6673 + $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6674 + $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6675 +
6676 + if (is_array($signature) && isset($signature['url'], $signature['hash'])) {
6677 + // Nothing we index changed — don't pay for another embedding.
6678 + if ($signature['url'] === $source_url && $signature['hash'] === md5($content)) {
6679 + return;
6680 + }
6681 + // The file was replaced under a new URL. md5(old_url) vectors would survive
6682 + // as an entry citing a file that no longer exists there, so clear them first.
6683 + if ($signature['url'] !== $source_url && $signature['url'] !== '') {
6684 + MxChat_Utils::delete_chunks_for_url($signature['url'], 'default');
6685 + }
6686 + }
6687 +
6688 + $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6689 + if (!$preflight['ok']) {
6690 + return;
6691 + }
6692 +
6693 + $result = MxChat_Utils::submit_content_to_db(
6694 + $content,
6695 + $source_url,
6696 + $preflight['api_key'],
6697 + md5($source_url),
6698 + 'default',
6699 + 'attachment'
6700 + );
6701 +
6702 + if (is_wp_error($result)) {
6703 + return;
6704 + }
6705 +
6706 + $this->apply_role_restriction_to_post($attachment_id, $source_url);
6707 + $this->mxchat_remember_attachment_signature($attachment_id, $source_url, $content);
6708 +}
6709 +
6710 +/**
6711 + * Remove an attachment's knowledge entry when the file is deleted.
6712 + *
6713 + * Fires on 'delete_attachment', which is the ONLY delete hook an attachment gets —
6714 + * wp_delete_post() hands attachments to wp_delete_attachment() before firing
6715 + * before_delete_post, so mxchat_handle_post_delete never runs for one.
6716 + *
6717 + * Gated on the same auto-sync toggle as the post delete path, for the same reason it
6718 + * is there: with auto-sync off, MxChat does not act on content changes by itself.
6719 + */
6720 +public function mxchat_handle_attachment_delete($attachment_id) {
6721 + $attachment_id = (int) $attachment_id;
6722 +
6723 + if (!$this->mxchat_attachment_auto_sync_enabled()) {
6724 + return;
6725 + }
6726 +
6727 + // Still resolvable — this hook fires before the row and the file are removed.
6728 + $source_url = wp_get_attachment_url($attachment_id);
6729 + $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6730 +
6731 + $urls = array();
6732 + if (!empty($source_url)) {
6733 + $urls[] = $source_url;
6734 + }
6735 + // A replaced-then-deleted file can still have vectors under the URL it was
6736 + // last indexed at, if that differs from where it lives now.
6737 + if (is_array($signature) && !empty($signature['url'])) {
6738 + $urls[] = $signature['url'];
6739 + }
6740 +
6741 + foreach (array_unique($urls) as $url) {
6742 + MxChat_Utils::delete_chunks_for_url($url, 'default');
6743 + }
6744 +}
6745 +
6746 +/**
6747 + * Handle ACF save - fires after ACF fields are saved
6748 + * This ensures ACF field data is available when syncing to knowledge base
6749 + */
6750 +public function mxchat_handle_acf_save($post_id) {
6751 + // Skip if not a valid post
6752 + if (!$post_id || $post_id === 'options') {
6753 + return;
6754 + }
6755 +
6756 + // Skip autosaves and revisions
6757 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6758 + return;
6759 + }
6760 +
6761 + $post = get_post($post_id);
6762 + if (!$post) {
6763 + return;
6764 + }
6765 +
6766 + $post_type = $post->post_type;
6767 +
6768 + // Check if sync is enabled for this post type
6769 + $should_sync = false;
6770 +
6771 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6772 + $should_sync = true;
6773 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6774 + $should_sync = true;
6775 + } else if ($post_type === 'product' && class_exists('WooCommerce')) {
6776 + // WooCommerce products - check if WooCommerce integration is enabled
6777 + $options = get_option('mxchat_options', array());
6778 + if (isset($options['enable_woocommerce_integration']) &&
6779 + ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
6780 + $should_sync = true;
6781 + }
6782 + } else {
6783 + // Check custom post types
6784 + $option_name = 'mxchat_auto_sync_' . $post_type;
6785 + if (get_option($option_name) === '1') {
6786 + $should_sync = true;
6787 + }
6788 + }
6789 +
6790 + if (!$should_sync) {
6791 + return;
6792 + }
6793 +
6794 + // Only process published posts
6795 + if ($post->post_status !== 'publish') {
6796 + return;
6797 + }
6798 +
6799 + // Check if this post has any ACF fields - if not, no need to re-sync
6800 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6801 + if (empty($acf_fields)) {
6802 + return;
6803 + }
6804 +
6805 + // Use a transient to prevent duplicate processing (post_updated may have already run)
6806 + $transient_key = 'mxchat_acf_synced_' . $post_id;
6807 + if (get_transient($transient_key)) {
6808 + return;
6809 + }
6810 + set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
6811 +
6812 + // Re-run the sync with ACF data now available
6813 + // We pass $update=true since this is effectively an update with ACF data
6814 + $this->mxchat_handle_post_update($post_id, $post, true);
6815 +}
6816 +
6817 +public function mxchat_handle_post_update($post_id, $post, $update) {
6818 + // The in-flight-update marker has done its job the moment post_updated runs; drop it
6819 + // before any early return so it can never outlive its own save (a failed $wpdb->update
6820 + // inside wp_insert_post returns after pre_post_update but before the transition).
6821 + unset($this->pending_post_update[$post_id]);
6822 +
6823 + // Basic validation checks
6824 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6825 + return;
6826 + }
6827 +
6828 + $post_type = $post->post_type;
6829 +
6830 + // Check if sync is enabled for this post type
6831 + $should_sync = false;
6832 +
6833 + // Check built-in post types first
6834 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6835 + $should_sync = true;
6836 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6837 + $should_sync = true;
6838 + } else {
6839 + // Check custom post types
6840 + $option_name = 'mxchat_auto_sync_' . $post_type;
6841 + if (get_option($option_name) === '1') {
6842 + $should_sync = true;
6843 + }
6844 + }
6845 +
6846 + if (!$should_sync) {
6847 + return;
6848 + }
6849 +
6850 + // Check if we have stored the previous status and URL in our transients
6851 + $previous_status_key = 'mxchat_prev_status_' . $post_id;
6852 + $previous_status = get_transient($previous_status_key);
6853 +
6854 + $previous_url_key = 'mxchat_prev_url_' . $post_id;
6855 + $previous_url = get_transient($previous_url_key);
6856 +
6857 + // If the post was previously published but is now not published, remove from knowledge base
6858 + if ($previous_status === 'publish' && $post->post_status !== 'publish') {
6859 + // Use the stored URL from when it was published, or fall back to current permalink
6860 + $source_url = $previous_url ?: get_permalink($post_id);
6861 +
6862 + // mxchat_handle_status_transition already deleted for this post earlier in this
6863 + // request (it fires first inside wp_insert_post); skip the redundant round-trip.
6864 + if ($source_url && empty($this->transition_deleted_posts[$post_id])) {
6865 + // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6866 + MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6867 + }
6868 +
6869 + // Clean up the transients and exit early
6870 + delete_transient($previous_status_key);
6871 + delete_transient($previous_url_key);
6872 + return;
6873 + }
6874 +
6875 + // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
6876 + // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
6877 + if ($post->post_status === 'publish' && !empty($previous_url)) {
6878 + $current_url = get_permalink($post_id);
6879 + if ($current_url && $current_url !== $previous_url) {
6880 + MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
6881 + }
6882 + }
6883 +
6884 + // Store the current status for next time (if this is an update)
6885 + if ($update) {
6886 + set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
6887 +
6888 + // If the post is currently published, also store its URL
6889 + if ($post->post_status === 'publish') {
6890 + $current_url = get_permalink($post_id);
6891 + set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
6892 + }
6893 + }
6894 +
6895 + // Only process currently published content for adding/updating.
6896 + // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have
6897 + // already indexed this post earlier in this request (editor publishes fire
6898 + // transition_post_status first, then post_updated) — skip the duplicate embed.
6899 + // Consume-once: the flag is cleared when honoured, so a LATER save of the same
6900 + // post in one long-running process (WP-CLI scripts, importers) re-indexes normally.
6901 + if ($post->post_status === 'publish') {
6902 + if (!empty($this->transition_indexed_posts[$post_id])) {
6903 + unset($this->transition_indexed_posts[$post_id]);
6904 + } else {
6905 + $this->mxchat_index_published_post($post_id, $post);
6906 + }
6907 + }
6908 +
6909 + // Clean up the stored previous status if not used above
6910 + if ($previous_status !== 'publish' || $post->post_status === 'publish') {
6911 + delete_transient($previous_status_key);
6912 + delete_transient($previous_url_key);
6913 + }
6914 +}
6915 +
6916 +/**
6917 + * Index a published post into the knowledge base: preprocessing filter, content
6918 + * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta,
6919 + * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and
6920 + * upsert, then tag-based role restriction.
6921 + *
6922 + * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the
6923 + * transition_post_status arrival edge (mxchat_handle_status_transition), so
6924 + * scheduled publishes (wp_publish_post) and direct status=publish inserts index
6925 + * identically to editor saves (plan 3055e1). Pure extraction of the former
6926 + * publish branch — body indentation retained to keep the diff reviewable.
6927 + */
6928 +private function mxchat_index_published_post($post_id, $post) {
6929 + $post_type = $post->post_type;
6930 +
6931 + // WooCommerce products are owned by the WC-object assembler (plan a3d60c):
6932 + // whenever WooCommerce is active AND the integration is enabled, every
6933 + // product save also fires save_post_product, which queues
6934 + // mxchat_store_product_embedding on shutdown — and that writer runs LAST,
6935 + // overwriting the same md5(permalink) row this path would write. Assembling
6936 + // and embedding the product here was pure duplicate spend (measured: two
6937 + // embedding calls per product save, second one wins). Skip ONLY under the
6938 + // exact conditions the shutdown writer runs — same option read as its own
6939 + // gate — because with the integration off (or WooCommerce inactive) this
6940 + // path is the sole product indexer and must keep working.
6941 + if ($post_type === 'product'
6942 + && class_exists('WooCommerce')
6943 + && isset($this->options['enable_woocommerce_integration'])
6944 + && in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6945 + return;
6946 + }
6947 +
6948 + // Get the source URL
6949 + $source_url = get_permalink($post_id);
6950 +
6951 + // A draft published programmatically (wp_publish_post) can reach this
6952 + // point with an EMPTY post_name — wp_insert_post skips slug generation
6953 + // for draft/pending — and get_permalink() then resolves to the bare
6954 + // site root. A knowledge row keyed to the homepage cites the wrong URL
6955 + // and answers homepage questions with this post's body, so refuse to
6956 + // write it; the post indexes correctly on its next save, once the slug
6957 + // exists. The empty-post_name test is what keeps a legitimate static
6958 + // front page (which has a slug but a root permalink) indexable.
6959 + // (Plan d138c4.)
6960 + if ('' === $post->post_name
6961 + && untrailingslashit($source_url) === untrailingslashit(home_url())) {
6962 + return;
6963 + }
6964 +
6965 + /**
6966 + * Allow developers to modify post data before processing into the knowledge base.
6967 + * Same filter and signature as the manual bulk-import path
6968 + * (ajax_mxchat_process_selected_content), so a callback registered once covers
6969 + * every indexing route. Purely additive — zero behaviour change when unhooked.
6970 + * Auto-sync runs under the 'default' bot context, matching the rest of this
6971 + * function.
6972 + *
6973 + * @param WP_Post $post The post about to be indexed.
6974 + * @param string $bot_id Bot context ('default' on auto-sync).
6975 + */
6976 + $post = apply_filters('mxchat_before_process_post', $post, 'default');
6977 + if (!($post instanceof WP_Post)) {
6978 + $post = get_post($post_id); // defend against a bad callback return
6979 + }
6980 +
6981 + // Assemble the indexable text via the shared post-kind assembler (a3d60c),
6982 + // reading from the FILTERED post object — not re-fetched by ID, which would
6983 + // discard it. Auto-sync reads content/excerpt in its historical
6984 + // get_post_field() display context, never appended product custom tabs
6985 + // (its product branch is reachable only with the WooCommerce integration
6986 + // off), and gates ACF→PDF extraction behind its own opt-in option —
6987 + // default OFF, because re-parsing every ACF PDF on every editor save is
6988 + // expensive and most sites don't want it (the 25 MB size cap lives in the
6989 + // shared extractor either way).
6990 + $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
6991 + 'read_display' => true,
6992 + 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1',
6993 + 'include_product_tabs' => false,
6994 + ));
6995 + $final_content = $prepared['content'];
6996 +
6997 + // Embedding decision — custom-provider-aware. Gating on a cloud API key
6998 + // here silently killed auto-sync on keyless custom-embeddings sites,
6999 + // because generate_embedding() routes custom FIRST and never needs the
7000 + // key (plan cbd5fd). Silent-return shape preserved.
7001 + $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
7002 + if (!$preflight['ok']) {
7003 + return;
7004 + }
7005 + $api_key = $preflight['api_key'];
7006 +
7007 + // Use the centralized utility function for storage
7008 + $result = MxChat_Utils::submit_content_to_db(
7009 + $final_content,
7010 + $source_url,
7011 + $api_key,
7012 + md5($source_url) // Vector ID for Pinecone
7013 + );
7014 +
7015 + // After successful storage, apply role restriction based on tags
7016 + if (!is_wp_error($result)) {
7017 + $this->apply_role_restriction_to_post($post_id, $source_url);
7018 + }
7019 +}
7020 +
7021 +/**
7022 + * Shared post-fields content assembler (plan a3d60c) — the ONE body behind both
7023 + * post-kind ingestion paths: manual bulk import (ajax_mxchat_process_selected_content)
7024 + * and auto-sync (mxchat_index_published_post). Behavior-preserving extraction; the
7025 + * measured per-caller differences ride $args instead of living as drifting copies:
7026 + *
7027 + * 'read_display' bool Auto-sync historically reads content/excerpt via
7028 + * get_post_field() in its default 'display' context
7029 + * (the post_content / post_excerpt display filters
7030 + * fire); bulk import reads the raw properties. Inert
7031 + * on a stock install — preserved per-path, not converged.
7032 + * 'extract_acf_pdfs' bool Each caller passes its OWN option (bulk:
7033 + * mxchat_acf_pdf_extraction; auto-sync:
7034 + * mxchat_auto_sync_acf_pdfs) — the two-option design
7035 + * is deliberate (plan 11720c). Gates BOTH the PDF-id
7036 + * collection walk and the extraction loop; the ids are
7037 + * only ever read inside the extraction branch, so
7038 + * gating collection is output-identical on every install.
7039 + * 'include_product_tabs' bool The bulk path has always appended yikes_woo custom
7040 + * tabs to product content; the auto-sync product branch
7041 + * (reachable only with the WooCommerce integration off)
7042 + * never did. Preserved per-path — converging it would be
7043 + * a behavior change, recorded on the plan instead.
7044 + *
7045 + * Returns array: 'content' (the assembled indexable text), 'acf_fields_found' and
7046 + * 'pdf_extracted_count' (the bulk path reports both in its AJAX response).
7047 + */
7048 +private function mxchat_prepare_post_content_for_indexing($post_id, $post, $args) {
7049 + $read_display = !empty($args['read_display']);
7050 + $extract_acf_pdfs = !empty($args['extract_acf_pdfs']);
7051 + $include_product_tabs = !empty($args['include_product_tabs']);
7052 +
7053 + // Raw post_title, NOT get_the_title(): the_title applies wptexturize +
7054 + // convert_chars and prepends the "Protected:" / "Private:" display chrome.
7055 + // The knowledge base stores facts, not display strings. Entity decode at
7056 + // output time (single-pass, shared helper) — a stored `&amp;` embeds worse
7057 + // than `&` and gets quoted back to visitors (d2c92e).
7058 + $content = $this->mxchat_decode_entities_for_indexing($post->post_title) . "\n\n";
7059 +
7060 + $raw_excerpt = $read_display ? get_post_field('post_excerpt', $post) : $post->post_excerpt;
7061 + $raw_content = $read_display ? get_post_field('post_content', $post) : $post->post_content;
7062 +
7063 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
7064 + // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to
7065 + // empty, and testing the raw value emitted a bare "Short Description: " label
7066 + // with no value after it. trim() only in the TEST — the emitted value is
7067 + // untouched, so a populated excerpt is byte-identical to before. A
7068 + // whitespace-only excerpt is an empty excerpt and must not produce a labelled
7069 + // line with nothing after it.
7070 + $clean_excerpt = $this->strip_shortcode_tags_preserve_content($raw_excerpt);
7071 + if (trim($clean_excerpt) !== '') {
7072 + $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n";
7073 + }
7074 +
7075 + // Main content — remove shortcode tags but preserve content inside them, then
7076 + // strip tags (don't use 'the_content' filter as it may re-add shortcodes).
7077 + $clean_content = $this->strip_shortcode_tags_preserve_content($raw_content);
7078 + $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content));
7079 +
7080 + // WooCommerce product enrichment (post-fields kind). The WC-object assembler
7081 + // (mxchat_prepare_product_content_for_indexing) owns product rows whenever the
7082 + // integration is on; this branch serves the bulk import (all configurations)
7083 + // and auto-sync with the integration off.
7084 + if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
7085 + $product = wc_get_product($post_id);
7086 +
7087 + if ($product) {
7088 + $content .= "\n";
7089 + $content .= $this->mxchat_woo_product_summary_lines($product);
7090 + }
7091 +
7092 + if ($include_product_tabs) {
7093 + $content .= $this->mxchat_woo_custom_tabs_text($post_id);
7094 + }
7095 + }
7096 +
7097 + // For custom post types like job_listing, include additional fields
7098 + if (get_post_type($post_id) === 'job_listing') {
7099 + // Add job-specific meta if available
7100 + $job_location = get_post_meta($post_id, '_job_location', true);
7101 + if (!empty($job_location)) {
7102 + $content .= "\n\nLocation: " . $job_location;
7103 + }
7104 +
7105 + // Get job type terms
7106 + $job_types = get_the_terms($post_id, 'job_listing_type');
7107 + if (!empty($job_types) && !is_wp_error($job_types)) {
7108 + $types = array();
7109 + foreach ($job_types as $type) {
7110 + $types[] = $type->name;
7111 + }
7112 + $content .= "\n\nJob Type: " . implode(', ', $types);
7113 + }
7114 +
7115 + // Get company name if available
7116 + $company_name = get_post_meta($post_id, '_company_name', true);
7117 + if (!empty($company_name)) {
7118 + $content .= "\n\nCompany: " . $company_name;
7119 + }
7120 + }
7121 +
7122 + // ADD ACF FIELDS SUPPORT
7123 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
7124 + $pdf_extracted_count = 0;
7125 + if (!empty($acf_fields)) {
7126 + $acf_content_parts = array();
7127 + $pdf_attachment_ids = array();
7128 +
7129 + foreach ($acf_fields as $field_name => $field_value) {
7130 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
7131 +
7132 + if (!empty($formatted_value)) {
7133 + // Both separators: a hyphenated ACF name should read as words.
7134 + $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
7135 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
7136 + }
7137 +
7138 + // Walk this field's value tree for any PDF attachment references and
7139 + // queue them for extraction — only when this caller's PDF option is on.
7140 + if ($extract_acf_pdfs) {
7141 + $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
7142 + }
7143 + }
7144 +
7145 + if (!empty($acf_content_parts)) {
7146 + $content .= "\n\n" . implode("\n", $acf_content_parts);
7147 + }
7148 +
7149 + // Extract text from each unique PDF found in ACF fields and append as a labeled section
7150 + if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
7151 + $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
7152 + $pdf_sections = array();
7153 + foreach ($pdf_attachment_ids as $att_id) {
7154 + $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
7155 + if (!empty($pdf_text)) {
7156 + $pdf_title = get_the_title($att_id);
7157 + $pdf_url = wp_get_attachment_url($att_id);
7158 + $header = 'PDF Attachment';
7159 + if (!empty($pdf_title)) {
7160 + $header .= ': ' . $pdf_title;
7161 + }
7162 + if (!empty($pdf_url)) {
7163 + $header .= ' (' . $pdf_url . ')';
7164 + }
7165 + $pdf_sections[] = $header . "\n" . $pdf_text;
7166 + $pdf_extracted_count++;
7167 + }
7168 + }
7169 + if (!empty($pdf_sections)) {
7170 + $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
7171 + }
7172 + }
7173 + }
7174 +
7175 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
7176 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
7177 + if (!empty($custom_meta)) {
7178 + $meta_content_parts = array();
7179 +
7180 + foreach ($custom_meta as $meta_key => $meta_value) {
7181 + // Convert meta key to readable label
7182 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
7183 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
7184 + }
7185 +
7186 + if (!empty($meta_content_parts)) {
7187 + $content .= "\n\n" . implode("\n", $meta_content_parts);
7188 + }
7189 + }
7190 +
7191 + return array(
7192 + 'content' => $content,
7193 + 'acf_fields_found' => count($acf_fields),
7194 + 'pdf_extracted_count' => $pdf_extracted_count,
7195 + );
7196 +}
7197 +
7198 +/**
7199 + * Shared WC-object product assembler (plan a3d60c) — the ONE body behind the two
7200 + * WooCommerce-object ingestion paths: the auto-sync product writer
7201 + * (mxchat_store_product_embedding) and the URL/sitemap product import
7202 + * (mxchat_extract_woocommerce_product_content). Assembles from the WC_Product,
7203 + * the authoritative source for product rows (scope decision on the plan).
7204 + */
7205 +private function mxchat_prepare_product_content_for_indexing($product) {
7206 + $title = $product->get_name();
7207 + $description = $product->get_description();
7208 + $short_description = $product->get_short_description();
7209 +
7210 + // Format content consistently
7211 + $content = $title . "\n\n";
7212 +
7213 + if (!empty($short_description)) {
7214 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7215 + }
7216 +
7217 + if (!empty($description)) {
7218 + $content .= wp_strip_all_tags($description) . "\n\n";
7219 + }
7220 +
7221 + $content .= $this->mxchat_woo_product_summary_lines($product);
7222 + $content .= $this->mxchat_woo_custom_tabs_text($product->get_id());
7223 +
7224 + return $content;
7225 +}
7226 +
7227 +/**
7228 + * Pricing + SKU + categories + tags lines for a product — shared by both assembler kinds
7229 + * (the post-fields product enrichment and the WC-object assembler).
7230 + */
7231 +private function mxchat_woo_product_summary_lines($product) {
7232 + // Add pricing information (base-currency pinned — see mxchat_product_price_lines)
7233 + $lines = $this->mxchat_product_price_lines($product);
7234 +
7235 + $sku = $product->get_sku();
7236 + if (!empty($sku)) {
7237 + $lines .= "SKU: " . $sku . "\n";
7238 + }
7239 +
7240 + // Get product categories
7241 + $categories = wp_get_post_terms($product->get_id(), 'product_cat', array('fields' => 'names'));
7242 + if (!empty($categories) && !is_wp_error($categories)) {
7243 + $lines .= "Categories: " . implode(', ', $categories) . "\n";
7244 + }
7245 +
7246 + // Product tags (plan 799cc4). A store's tags often carry the words a
7247 + // visitor asks with ("vegan", "gift") and none of them are in the
7248 + // description. No line at all when the product has no tags.
7249 + $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
7250 + if (!empty($tags) && !is_wp_error($tags)) {
7251 + $lines .= "Tags: " . $this->mxchat_decode_entities_for_indexing(implode(', ', $tags)) . "\n";
7252 + }
7253 +
7254 + return $lines;
7255 +}
7256 +
7257 +/**
7258 + * Custom Product Tabs text (supports "Custom Product Tabs for WooCommerce" by
7259 + * Code Parrots) — direct tabs plus applied reusable/saved tabs. The ONE copy of
7260 + * the yikes_woo logic; three sites carried byte-identical clones before a3d60c.
7261 + */
7262 +private function mxchat_woo_custom_tabs_text($product_id) {
7263 + $text = '';
7264 +
7265 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7266 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
7267 + foreach ($custom_tabs as $tab) {
7268 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7269 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
7270 +
7271 + if (!empty($tab_title) && !empty($tab_content)) {
7272 + $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7273 + }
7274 + }
7275 + }
7276 +
7277 + // Also check for reusable/saved tabs applied to this product
7278 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7279 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7280 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7281 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
7282 + foreach ($applied_saved_tabs as $saved_tab_id) {
7283 + if (isset($saved_tabs[$saved_tab_id])) {
7284 + $tab = $saved_tabs[$saved_tab_id];
7285 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7286 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
7287 +
7288 + if (!empty($tab_title) && !empty($tab_content)) {
7289 + $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7290 + }
7291 + }
7292 + }
7293 + }
7294 + }
7295 +
7296 + return $text;
7297 +}
7298 +
7299 +/**
7300 + * Store the post status and URL before update to detect status transitions
7301 + * This runs before the post is actually updated in the database
7302 + */
7303 +public function mxchat_store_pre_update_status($post_id, $data) {
7304 + // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later
7305 + // this request and can consume the arrival-edge guard (plan a664f3).
7306 + $this->pending_post_update[$post_id] = true;
7307 +
7308 + // Get the current post from database (before update)
7309 + $current_post = get_post($post_id);
7310 +
7311 + if ($current_post) {
7312 + // Store the current status temporarily
7313 + $status_key = 'mxchat_prev_status_' . $post_id;
7314 + set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
7315 +
7316 + // If the post is currently published, also store its URL
7317 + if ($current_post->post_status === 'publish') {
7318 + $url_key = 'mxchat_prev_url_' . $post_id;
7319 + $current_url = get_permalink($post_id);
7320 + set_transient($url_key, $current_url, HOUR_IN_SECONDS);
7321 + }
7322 + }
7323 +}
7324 +
7325 +/**
7326 + * Whether auto-sync is enabled for a post type (mirrors the checks used by the
7327 + * update/delete handlers; kept as one helper so new call sites cannot drift).
7328 + */
7329 +private function mxchat_is_auto_sync_enabled($post_type) {
7330 + if ($post_type === 'post') {
7331 + return get_option('mxchat_auto_sync_posts') === '1';
7332 + }
7333 + if ($post_type === 'page') {
7334 + return get_option('mxchat_auto_sync_pages') === '1';
7335 + }
7336 + return get_option('mxchat_auto_sync_' . $post_type) === '1';
7337 +}
7338 +
7339 +/**
7340 + * Remove a post's vectors the moment it leaves 'publish', using the authoritative
7341 + * old status core passes to transition_post_status — no transient involved (plan 816fb1).
7342 + *
7343 + * Covers status changes that never route through wp_update_post (scheduled-expiry
7344 + * plugins and others that flip post_status directly and call wp_transition_post_status),
7345 + * where neither pre_post_update nor post_updated fires and the old detection missed.
7346 + */
7347 +public function mxchat_handle_status_transition($new_status, $old_status, $post) {
7348 + if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) {
7349 + return;
7350 + }
7351 +
7352 + // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because
7353 + // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post —
7354 + // and direct wp_insert_post(status=publish) creates never fire post_updated, so the
7355 + // auto-sync ADD path alone misses them. Editor publishes also pass through here;
7356 + // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding
7357 + // a second time in the same request.
7358 + if ($new_status === 'publish' && $old_status !== 'publish') {
7359 + if ($this->mxchat_is_auto_sync_enabled($post->post_type)) {
7360 + $this->mxchat_index_published_post($post->ID, $post);
7361 +
7362 + // Arm the double-fire guard ONLY when a post_updated is actually coming to
7363 + // consume it (plan a664f3). Two publish paths never fire post_updated at all:
7364 + // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the
7365 + // call check_and_publish_future_post() makes for scheduled posts. Arming the
7366 + // guard unconditionally left it set with nothing to consume it, so the NEXT
7367 + // update of that post was swallowed entirely: zero embed calls, no knowledge
7368 + // -base row, silently. Consume-once on this side too, so a guard can never
7369 + // outlive the single save it was armed for.
7370 + if (!empty($this->pending_post_update[$post->ID])) {
7371 + unset($this->pending_post_update[$post->ID]);
7372 + $this->transition_indexed_posts[$post->ID] = true;
7373 + }
7374 + }
7375 + return;
7376 + }
7377 +
7378 + // Only the publish -> not-publish edge matters here.
7379 + if ($old_status !== 'publish' || $new_status === 'publish') {
7380 + return;
7381 + }
7382 + // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL
7383 + // resolution; skip to avoid a second network round-trip per trash.
7384 + if ($new_status === 'trash') {
7385 + return;
7386 + }
7387 + if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) {
7388 + return;
7389 + }
7390 +
7391 + $urls = array();
7392 +
7393 + // The DB may already hold the new status when this fires, so get_permalink() on the
7394 + // live post could build a draft-style URL whose md5 misses the stored vector IDs.
7395 + // Reconstruct the published permalink from a clone instead.
7396 + $published_clone = clone $post;
7397 + $published_clone->post_status = 'publish';
7398 + $published_url = get_permalink($published_clone);
7399 + if ($published_url) {
7400 + $urls[] = $published_url;
7401 + }
7402 +
7403 + // Honour the pre-update capture when present (covers a slug change in the same save).
7404 + $previous_url = get_transient('mxchat_prev_url_' . $post->ID);
7405 + if (!empty($previous_url)) {
7406 + $urls[] = $previous_url;
7407 + }
7408 +
7409 + foreach (array_unique($urls) as $url) {
7410 + MxChat_Utils::delete_chunks_for_url($url, 'default');
7411 + }
7412 +
7413 + if (!empty($urls)) {
7414 + $this->transition_deleted_posts[$post->ID] = true;
7415 + }
7416 +}
7417 +
7418 +/**
7419 + * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished,
7420 + * trashed, or made private before the transition_post_status handler existed.
7421 + *
7422 + * Walks every auto-synced post type's non-published posts, reconstructs each one's
7423 + * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table).
7424 + * Deletion is idempotent, so never-indexed posts are a cheap no-op.
7425 + *
7426 + * ## OPTIONS
7427 + *
7428 + * [--dry-run]
7429 + * : Report what would be removed without deleting anything.
7430 + *
7431 + * ## EXAMPLES
7432 + *
7433 + * wp mxchat prune-unpublished --dry-run
7434 + * wp mxchat prune-unpublished
7435 + */
7436 +public function cli_prune_unpublished($args, $assoc_args) {
7437 + global $wpdb;
7438 + $dry_run = !empty($assoc_args['dry-run']);
7439 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7440 +
7441 + $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names')));
7442 + $synced_types = array();
7443 + foreach ($candidate_types as $type) {
7444 + if ($this->mxchat_is_auto_sync_enabled($type)) {
7445 + $synced_types[] = $type;
7446 + }
7447 + }
7448 + if (empty($synced_types)) {
7449 + WP_CLI::success('No post types have auto-sync enabled; nothing to prune.');
7450 + return;
7451 + }
7452 +
7453 + $scanned = 0;
7454 + $pruned = 0;
7455 + $paged = 1;
7456 + do {
7457 + $query = new WP_Query(array(
7458 + 'post_type' => $synced_types,
7459 + 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'),
7460 + 'posts_per_page' => 100,
7461 + 'paged' => $paged,
7462 + 'fields' => 'ids',
7463 + ));
7464 + foreach ($query->posts as $post_id) {
7465 + $post = get_post($post_id);
7466 + if (!$post) {
7467 + continue;
7468 + }
7469 + $scanned++;
7470 +
7471 + // Rebuild the permalink the post had while published: publish-status clone,
7472 + // with wp_trash_post's __trashed slug suffix stripped for trashed posts.
7473 + $clone = clone $post;
7474 + $clone->post_status = 'publish';
7475 + if (substr($clone->post_name, -9) === '__trashed') {
7476 + $clone->post_name = substr($clone->post_name, 0, -9);
7477 + }
7478 + $url = get_permalink($clone);
7479 + if (!$url) {
7480 + continue;
7481 + }
7482 +
7483 + // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it
7484 + // reads 0 but the delete below still routes to Pinecone and is idempotent.
7485 + $local_rows = (int) $wpdb->get_var($wpdb->prepare(
7486 + "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url
7487 + ));
7488 +
7489 + if ($dry_run) {
7490 + if ($local_rows > 0) {
7491 + WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7492 + $pruned += $local_rows;
7493 + }
7494 + continue;
7495 + }
7496 +
7497 + MxChat_Utils::delete_chunks_for_url($url, 'default');
7498 + if ($local_rows > 0) {
7499 + WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7500 + $pruned += $local_rows;
7501 + }
7502 + }
7503 + $more = $paged < $query->max_num_pages;
7504 + $paged++;
7505 + } while ($more);
7506 +
7507 + WP_CLI::success(sprintf(
7508 + '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s',
7509 + $dry_run ? 'Would remove' : 'Removed',
7510 + $pruned,
7511 + $scanned,
7512 + ' (Pinecone-mode deletions are not counted locally.)'
7513 + ));
7514 +}
7515 +
7516 +/**
7517 + * WP-CLI: repair knowledge-base rows whose PDF text was imported in visual
7518 + * (reversed) order before the RTL normalizer existed. 32bf9e fixed new
7519 + * imports only; this fixes rows already in the table without the customer
7520 + * having to re-source and re-upload the original PDFs (plan d1e6f7).
7521 + *
7522 + * Detection reuses MxChat_Utils::normalize_pdf_rtl() on the stored text: a
7523 + * row is a candidate exactly when the normalizer would change it, so the
7524 + * import-time heuristic and the repair heuristic can never disagree.
7525 + * Repaired rows are RE-EMBEDDED — the stored vector was computed over
7526 + * reversed text and is as broken as the text — so a wet run calls the
7527 + * embedding provider once per repaired row on the site's API key. Runs
7528 + * beyond 25 rows therefore require --yes.
7529 + *
7530 + * Scope notes:
7531 + * - Scans the WordPress knowledge table. Pinecone-mode entries live in
7532 + * Pinecone, not this table, and are not scanned; if a scanned row's bot
7533 + * ALSO has Pinecone enabled (hybrid drift), the repaired entry is
7534 + * re-submitted through the normal import path so the md5-keyed Pinecone
7535 + * vector is replaced too.
7536 + * - Knowledge rows do not carry a bot id; --bot only selects whose
7537 + * embedding configuration (model + key) is used for re-embedding.
7538 + * - The mxchat_pdf_rtl_normalize filter is honoured: a site that disabled
7539 + * normalization gets detections of zero, not surprise rewrites.
7540 + * - The metadata header the PDF importer stores before the text separator
7541 + * is preserved byte-identical; only the text segment is repaired.
7542 + *
7543 + * ## OPTIONS
7544 + *
7545 + * [--dry-run]
7546 + * : List the rows that would be repaired without changing anything.
7547 + *
7548 + * [--bot=<id>]
7549 + * : Embedding configuration to use for re-embedding. Default: default.
7550 + *
7551 + * [--all-content]
7552 + * : Scan every row containing right-to-left text, not just rows with PDF
7553 + * provenance (a page anchor in the source URL, or pdf content type).
7554 + *
7555 + * [--yes]
7556 + * : Proceed even when more than 25 rows need re-embedding (API cost gate).
7557 + *
7558 + * ## EXAMPLES
7559 + *
7560 + * wp mxchat rtl-repair --dry-run
7561 + * wp mxchat rtl-repair
7562 + * wp mxchat rtl-repair --all-content --yes
7563 + */
7564 +public function cli_rtl_repair($args, $assoc_args) {
7565 + global $wpdb;
7566 + $dry_run = !empty($assoc_args['dry-run']);
7567 + $all = !empty($assoc_args['all-content']);
7568 + $yes = !empty($assoc_args['yes']);
7569 + $bot_id = isset($assoc_args['bot']) ? sanitize_key($assoc_args['bot']) : 'default';
7570 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7571 +
7572 + // Detection pass — no API calls. Walk the table in id batches so a large
7573 + // knowledge base never loads at once.
7574 + $rtl_re = '/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u';
7575 + $candidates = array();
7576 + $scanned = 0;
7577 + $last_id = 0;
7578 + do {
7579 + if ($all) {
7580 + $rows = $wpdb->get_results($wpdb->prepare(
7581 + "SELECT id, article_content, source_url, content_type FROM {$table}
7582 + WHERE id > %d ORDER BY id ASC LIMIT 200",
7583 + $last_id
7584 + ));
7585 + } else {
7586 + $rows = $wpdb->get_results($wpdb->prepare(
7587 + "SELECT id, article_content, source_url, content_type FROM {$table}
7588 + WHERE id > %d AND (source_url LIKE %s OR content_type = 'pdf')
7589 + ORDER BY id ASC LIMIT 200",
7590 + $last_id,
7591 + '%' . $wpdb->esc_like('#page=') . '%'
7592 + ));
7593 + }
7594 + foreach ($rows as $row) {
7595 + $last_id = (int) $row->id;
7596 + $scanned++;
7597 + $content = (string) $row->article_content;
7598 + if (!preg_match($rtl_re, $content)) {
7599 + continue;
7600 + }
7601 + list($header, $text) = $this->mxchat_rtl_repair_split($content);
7602 + $normalized = MxChat_Utils::normalize_pdf_rtl($text, 'rtl-repair row ' . $row->id);
7603 + if (is_string($normalized) && $normalized !== $text) {
7604 + $candidates[] = array(
7605 + 'id' => (int) $row->id,
7606 + 'source_url' => (string) $row->source_url,
7607 + 'content_type' => (string) $row->content_type,
7608 + 'new_content' => $header . $normalized,
7609 + );
7610 + }
7611 + }
7612 + } while (count($rows) === 200);
7613 +
7614 + WP_CLI::log(sprintf('Scanned %d row(s); %d stored in reversed (visual) order.', $scanned, count($candidates)));
7615 + if (empty($candidates)) {
7616 + WP_CLI::success('No reversed RTL rows found — nothing to repair.');
7617 + return;
7618 + }
7619 +
7620 + foreach ($candidates as $c) {
7621 + WP_CLI::log(sprintf('%s row %d %s', $dry_run ? 'Would repair' : 'Will repair', $c['id'], $c['source_url']));
7622 + }
7623 + if ($dry_run) {
7624 + WP_CLI::success(sprintf('Dry run: %d row(s) would be repaired and re-embedded. Run without --dry-run to apply.', count($candidates)));
7625 + return;
7626 + }
7627 +
7628 + // Cost gate: re-embedding spends the customer's API budget.
7629 + WP_CLI::log(sprintf('Re-embedding will call the embedding provider once per row — %d call(s) on this site\'s API key.', count($candidates)));
7630 + if (count($candidates) > 25 && !$yes) {
7631 + WP_CLI::error(sprintf('%d rows need re-embedding (more than 25). Re-run with --yes to confirm the API cost. No rows were changed.', count($candidates)));
7632 + }
7633 +
7634 + $bot_options = $this->get_bot_options($bot_id);
7635 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7636 + $preflight = MxChat_Utils::embedding_preflight($options);
7637 + if (!$preflight['ok']) {
7638 + WP_CLI::error('Embedding configuration problem: ' . $preflight['reason']);
7639 + }
7640 + $api_key = $preflight['api_key'];
7641 +
7642 + $pinecone_hybrid = $this->mxchat_rtl_repair_pinecone_enabled($bot_id);
7643 + $repaired = 0;
7644 + $failed = 0;
7645 + foreach ($candidates as $c) {
7646 + $vector = MxChat_Utils::regenerate_embedding($c['new_content'], $api_key, $bot_id);
7647 + if (!is_array($vector)) {
7648 + $failed++;
7649 + $reason = is_wp_error($vector) ? $vector->get_error_message() : 'embedding request failed';
7650 + // Text and vector must stay consistent: never write repaired text
7651 + // beside the stale reversed-text vector.
7652 + WP_CLI::warning(sprintf('Row %d NOT repaired — %s. Row left unchanged.', $c['id'], $reason));
7653 + continue;
7654 + }
7655 + $wpdb->update(
7656 + $table,
7657 + array(
7658 + 'article_content' => $c['new_content'],
7659 + 'embedding_vector' => maybe_serialize($vector),
7660 + ),
7661 + array('id' => $c['id']),
7662 + array('%s', '%s'),
7663 + array('%d')
7664 + );
7665 + $repaired++;
7666 + if (class_exists('MxChat_Admin')) {
7667 + MxChat_Admin::mxchat_log_debug('pdf_rtl_repaired', 'Stored KB row restored to logical order and re-embedded', array(
7668 + 'row_id' => $c['id'],
7669 + 'source_url' => $c['source_url'],
7670 + 'bot' => $bot_id,
7671 + ));
7672 + }
7673 + // Hybrid drift: the bot indexes into Pinecone but this row sat in the
7674 + // WP table — push the repaired entry through the normal import path so
7675 + // the md5(source_url)-keyed Pinecone vector is replaced as well.
7676 + if ($pinecone_hybrid) {
7677 + MxChat_Utils::submit_content_to_db(
7678 + $c['new_content'],
7679 + $c['source_url'],
7680 + $api_key,
7681 + null,
7682 + $bot_id,
7683 + $c['content_type'] !== '' ? $c['content_type'] : 'pdf'
7684 + );
7685 + }
7686 + }
7687 +
7688 + WP_CLI::success(sprintf('Repaired + re-embedded %d row(s); %d failed; %d scanned.', $repaired, $failed, $scanned));
7689 +}
7690 +
7691 +/**
7692 + * Split a stored KB row into (metadata header incl. separator, text segment).
7693 + * The PDF importer stores wp_json_encode($metadata) . "\n---\n" . $text —
7694 + * repair must touch only the text and keep the header byte-identical.
7695 + */
7696 +private function mxchat_rtl_repair_split($content) {
7697 + $sep = "\n---\n";
7698 + $pos = strpos($content, $sep);
7699 + if ($pos !== false && $pos > 0 && $content[0] === '{') {
7700 + $maybe_json = substr($content, 0, $pos);
7701 + if (json_decode($maybe_json) !== null) {
7702 + return array(substr($content, 0, $pos + strlen($sep)), substr($content, $pos + strlen($sep)));
7703 + }
7704 + }
7705 + return array('', $content);
7706 +}
7707 +
7708 +/**
7709 + * Mirror of MxChat_Utils::is_pinecone_enabled_for_bot() (private there) for
7710 + * the repair CLI's hybrid-drift check.
7711 + */
7712 +private function mxchat_rtl_repair_pinecone_enabled($bot_id) {
7713 + if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
7714 + $cfg = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
7715 + if (!empty($cfg)) {
7716 + return !empty($cfg['use_pinecone']) && !empty($cfg['api_key']) && !empty($cfg['host']);
7717 + }
7718 + }
7719 + $po = get_option('mxchat_pinecone_addon_options');
7720 + return !empty($po['mxchat_use_pinecone']) && $po['mxchat_use_pinecone'] !== '0'
7721 + && !empty($po['mxchat_pinecone_api_key']) && !empty($po['mxchat_pinecone_host']);
7722 +}
7723 +
7724 +/* =========================================================================
7725 + * Re-import affected entries (plan 89739b)
7726 + *
7727 + * Several releases corrected how knowledge text is imported, each only for
7728 + * entries added afterwards: HTML entities (d2c92e), currency signs (7403ec),
7729 + * combining marks and symbols (a19914), joiner characters (209e57) and
7730 + * right-to-left PDF order (32bf9e). This walks the knowledge base, rebuilds
7731 + * each entry's text from its source with the CURRENT import chain, and
7732 + * rewrites an entry only when (a) the rebuilt text differs from the stored
7733 + * text in one of those ways and (b) the two are otherwise the same document.
7734 + * An entry whose source simply changed since it was imported is reported and
7735 + * left alone: that is a different question from "was it damaged at import".
7736 + *
7737 + * One engine, two callers: wp mxchat kb-reimport and the Knowledge screen's
7738 + * resumable action. A scan makes no embedding call and writes no entry.
7739 + * ====================================================================== */
7740 +
7741 +/** Release date of 3.2.20, the last release that changed how text is imported. */
7742 +const REIMPORT_DEFAULT_BEFORE = '2026-08-28';
7743 +const REIMPORT_STATE_OPTION = 'mxchat_kb_reimport_state';
7744 +
7745 +/** Which store holds the entries for this bot: wordpress, pinecone or documents. */
7746 +private function mxchat_reimport_store($bot_id) {
7747 + if (!$this->mxchat_rtl_repair_pinecone_enabled($bot_id)) {
7748 + return 'wordpress';
7749 + }
7750 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
7751 + return 'documents';
7752 + }
7753 + return 'pinecone';
7754 +}
7755 +
7756 +private function mxchat_reimport_pinecone_cfg($bot_id) {
7757 + $po = class_exists('MxChat_Pinecone_Manager')
7758 + ? MxChat_Pinecone_Manager::get_instance()->mxchat_get_bot_pinecone_options($bot_id)
7759 + : get_option('mxchat_pinecone_addon_options', array());
7760 + return array(
7761 + 'api_key' => (string) ($po['mxchat_pinecone_api_key'] ?? ''),
7762 + 'host' => (string) ($po['mxchat_pinecone_host'] ?? ''),
7763 + 'namespace' => (string) ($po['mxchat_pinecone_namespace'] ?? ''),
7764 + );
7765 +}
7766 +
7767 +private function mxchat_reimport_new_state($args) {
7768 + $bot_id = isset($args['bot']) ? sanitize_key($args['bot']) : 'default';
7769 + $before = isset($args['before']) ? (string) $args['before'] : self::REIMPORT_DEFAULT_BEFORE;
7770 + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $before)) {
7771 + $before = self::REIMPORT_DEFAULT_BEFORE;
7772 + }
7773 + global $wpdb;
7774 + $store = $this->mxchat_reimport_store($bot_id);
7775 + return array(
7776 + 'phase' => 'scan',
7777 + 'bot' => $bot_id,
7778 + 'store' => $store,
7779 + 'source' => isset($args['source']) && $args['source'] !== '' ? (string) $args['source'] : 'all',
7780 + 'before' => $before,
7781 + 'cap' => isset($args['cap']) ? (int) $args['cap'] : 100,
7782 + 'cursor' => $store === 'wordpress' ? 0 : '',
7783 + 'max_id' => $store === 'wordpress' ? (int) $wpdb->get_var("SELECT MAX(id) FROM {$wpdb->prefix}mxchat_system_prompt_content") : 0,
7784 + 'scan_done' => false,
7785 + 'scanned' => 0,
7786 + 'unchanged' => 0,
7787 + 'changed' => 0,
7788 + 'skipped' => 0,
7789 + 'candidates' => array(),
7790 + 'notes' => array(),
7791 + 'pos' => 0,
7792 + 'repaired' => 0,
7793 + 'failed' => 0,
7794 + 'error' => '',
7795 + 'updated' => time(),
7796 + );
7797 +}
7798 +
7799 +/** True when the entry passes --source and --before. */
7800 +private function mxchat_reimport_selected(array $entry, array $state) {
7801 + $url = (string) $entry['source_url'];
7802 + $source = (string) $state['source'];
7803 + if ($source === 'upload') {
7804 + if (!MxChat_Utils::is_upload_source($url)) {
7805 + return false;
7806 + }
7807 + } elseif ($source === 'manual') {
7808 + if ($url !== '' && strpos($url, 'mxchat://') !== 0) {
7809 + return false;
7810 + }
7811 + } elseif ($source !== 'all' && $source !== '') {
7812 + if (strpos($url, $source) !== 0) {
7813 + return false;
7814 + }
7815 + }
7816 + $limit = strtotime($state['before'] . ' 00:00:00');
7817 + $when = $entry['updated'];
7818 + if (is_string($when) && $when !== '' && !ctype_digit($when)) {
7819 + $when = strtotime($when);
7820 + }
7821 + $when = (int) $when;
7822 + // An entry with no date on record predates the date field: treat it as old.
7823 + return ($when === 0) || ($limit === false) || ($when < $limit);
7824 +}
7825 +
7826 +/**
7827 + * One page of entries (not rows: a chunked entry is one entry).
7828 + * @return array{entries:array,done:bool}|WP_Error
7829 + */
7830 +private function mxchat_reimport_list_page(array &$state, $limit) {
7831 + if ($state['store'] === 'wordpress') {
7832 + global $wpdb;
7833 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7834 + $rows = $wpdb->get_results($wpdb->prepare(
7835 + "SELECT MIN(id) AS first_id, MAX(source_url) AS source_url, MAX(content_type) AS content_type,
7836 + MAX(`timestamp`) AS updated, COUNT(*) AS n
7837 + FROM {$table}
7838 + GROUP BY CASE WHEN source_url IS NULL OR source_url = '' THEN CONCAT('#', id) ELSE source_url END
7839 + HAVING first_id > %d AND first_id <= %d
7840 + ORDER BY first_id ASC LIMIT %d",
7841 + (int) $state['cursor'], (int) $state['max_id'], (int) $limit
7842 + ));
7843 + $entries = array();
7844 + foreach ((array) $rows as $row) {
7845 + $state['cursor'] = (int) $row->first_id;
7846 + $entries[] = array(
7847 + 'store' => 'wordpress',
7848 + 'id' => (int) $row->first_id,
7849 + 'source_url' => (string) $row->source_url,
7850 + 'content_type' => (string) $row->content_type,
7851 + 'updated' => (string) $row->updated,
7852 + 'chunked' => ((int) $row->n) > 1,
7853 + );
7854 + }
7855 + return array('entries' => $entries, 'done' => count((array) $rows) < $limit);
7856 + }
7857 +
7858 + $cfg = $this->mxchat_reimport_pinecone_cfg($state['bot']);
7859 + if ($cfg['api_key'] === '' || $cfg['host'] === '') {
7860 + return new WP_Error('pinecone_config', __('Pinecone is switched on but has no API key or host.', 'mxchat'));
7861 + }
7862 + $is_docs = ($state['store'] === 'documents');
7863 + $page = $is_docs
7864 + ? MxChat_Pinecone_Documents::list_page('', $limit, (string) $state['cursor'], $cfg)
7865 + : MxChat_Pinecone_Documents::classic_list_page($cfg['host'], $cfg['api_key'], $cfg['namespace'], $limit, (string) $state['cursor']);
7866 + if (is_wp_error($page)) {
7867 + return $page;
7868 + }
7869 + // A chunked entry is taken once, at its first chunk; the other chunk ids are passed over.
7870 + $anchors = array();
7871 + foreach ($page['ids'] as $id) {
7872 + if (preg_match('/_chunk_(\d+)$/', $id, $m) && (int) $m[1] !== 0) {
7873 + continue;
7874 + }
7875 + $anchors[] = $id;
7876 + }
7877 + $records = array();
7878 + if (!empty($anchors)) {
7879 + $records = $is_docs
7880 + ? MxChat_Pinecone_Documents::fetch($anchors, $cfg)
7881 + : MxChat_Pinecone_Documents::classic_fetch($cfg['host'], $cfg['api_key'], $cfg['namespace'], $anchors);
7882 + if (is_wp_error($records)) {
7883 + return $records;
7884 + }
7885 + }
7886 + $entries = array();
7887 + foreach ($anchors as $id) {
7888 + if (!isset($records[$id])) {
7889 + continue;
7890 + }
7891 + $meta = isset($records[$id]['metadata']) && is_array($records[$id]['metadata']) ? $records[$id]['metadata'] : array();
7892 + $entries[] = array(
7893 + 'store' => $state['store'],
7894 + 'id' => (string) $id,
7895 + 'source_url' => (string) ($meta['source_url'] ?? ''),
7896 + 'content_type' => (string) ($meta['type'] ?? 'content'),
7897 + 'updated' => (int) ($meta['last_updated'] ?? 0),
7898 + 'chunked' => (bool) preg_match('/_chunk_0$/', $id),
7899 + 'text' => (string) ($meta['text'] ?? ''),
7900 + );
7901 + }
7902 + $state['cursor'] = (string) $page['next'];
7903 + return array('entries' => $entries, 'done' => ((string) $page['next'] === ''));
7904 +}
7905 +
7906 +/** The entry's stored text, chunks joined. @return string|WP_Error */
7907 +private function mxchat_reimport_stored_text(array $entry, $bot_id) {
7908 + if ($entry['store'] === 'wordpress') {
7909 + $got = $this->get_wordpress_entry_content($entry['source_url'], (int) $entry['id']);
7910 + } elseif (empty($entry['chunked']) && isset($entry['text'])) {
7911 + return (string) $entry['text'];
7912 + } else {
7913 + // A chunked entry, or one read back from a saved scan (which keeps no text).
7914 + $got = $this->get_pinecone_entry_content($entry['source_url'], (string) $entry['id'], $bot_id);
7915 + }
7916 + return is_wp_error($got) ? $got : (string) $got['content'];
7917 +}
7918 +
7919 +private function mxchat_reimport_is_pdf(array $entry) {
7920 + $url = (string) $entry['source_url'];
7921 + return $entry['content_type'] === 'pdf'
7922 + || strpos($url, '#page=') !== false
7923 + || (MxChat_Utils::is_upload_source($url) && (bool) preg_match('/\.pdf$/i', (string) MxChat_Utils::upload_source_base($url)));
7924 +}
7925 +
7926 +/** Text that came out of the PDF reader, page entry or whole file: reading order can apply. */
7927 +private function mxchat_reimport_from_pdf(array $entry) {
7928 + return $this->mxchat_reimport_is_pdf($entry) || (bool) preg_match('/\.pdf(?:[?#]|$)/i', (string) $entry['source_url']);
7929 +}
7930 +
7931 +private function mxchat_reimport_squash($text) {
7932 + $out = preg_replace('/[\s\p{Z}]+/u', '', (string) $text);
7933 + return is_string($out) ? $out : (string) $text;
7934 +}
7935 +
7936 +/**
7937 + * Comparison form only, never stored. Both texts are reduced to what the
7938 + * oldest import chain kept (letters, numbers, punctuation, math symbols:
7939 + * the allowlist that shipped until 3.2.18), with entities decoded and
7940 + * right-to-left PDF text put in reading order. Two texts with the same form
7941 + * are the same document, differing only in what the fixes restored.
7942 + */
7943 +private function mxchat_reimport_norm($text, $is_pdf) {
7944 + $text = (string) $text;
7945 + for ($i = 0; $i < 3; $i++) {
7946 + $decoded = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
7947 + if ($decoded === $text) {
7948 + break;
7949 + }
7950 + $text = $decoded;
7951 + }
7952 + if ($is_pdf) {
7953 + $logical = MxChat_Utils::normalize_pdf_rtl($text, 'kb-reimport compare');
7954 + if (is_string($logical)) {
7955 + $text = $logical;
7956 + }
7957 + }
7958 + $kept = preg_replace('/[^\p{L}\p{N}\p{P}\p{Sm}]+/u', '', $text);
7959 + return is_string($kept) ? $kept : $this->mxchat_reimport_squash($text);
7960 +}
7961 +
7962 +private function mxchat_reimport_count($pattern, $text) {
7963 + $n = preg_match_all($pattern, (string) $text);
7964 + return $n ? (int) $n : 0;
7965 +}
7966 +
7967 +private function mxchat_reimport_entity_count($text) {
7968 + if (!preg_match_all('/&(?:#[0-9]{1,7}|#x[0-9a-f]{1,6}|[a-z][a-z0-9]{1,31});/i', (string) $text, $m)) {
7969 + return 0;
7970 + }
7971 + $n = 0;
7972 + foreach ($m[0] as $entity) {
7973 + if (html_entity_decode($entity, ENT_QUOTES | ENT_HTML5, 'UTF-8') !== $entity) {
7974 + $n++;
7975 + }
7976 + }
7977 + return $n;
7978 +}
7979 +
7980 +/** Why the rebuilt text differs from the stored text, in the fixes' own terms. */
7981 +private function mxchat_reimport_reasons($stored, $fresh, $is_pdf) {
7982 + $reasons = array();
7983 + if ($this->mxchat_reimport_entity_count($stored) > $this->mxchat_reimport_entity_count($fresh)) {
7984 + $reasons[] = 'entities';
7985 + }
7986 + if ($this->mxchat_reimport_count('/\p{Sc}/u', $fresh) > $this->mxchat_reimport_count('/\p{Sc}/u', $stored)) {
7987 + $reasons[] = 'currency';
7988 + }
7989 + if ($this->mxchat_reimport_count('/\p{M}/u', $fresh) > $this->mxchat_reimport_count('/\p{M}/u', $stored)) {
7990 + $reasons[] = 'combining marks';
7991 + }
7992 + if ($this->mxchat_reimport_count('/\p{So}/u', $fresh) > $this->mxchat_reimport_count('/\p{So}/u', $stored)) {
7993 + $reasons[] = 'symbols';
7994 + }
7995 + if ($this->mxchat_reimport_count('/[\x{200C}\x{200D}]/u', $fresh) > $this->mxchat_reimport_count('/[\x{200C}\x{200D}]/u', $stored)) {
7996 + $reasons[] = 'joiners';
7997 + }
7998 + if ($is_pdf) {
7999 + $logical = MxChat_Utils::normalize_pdf_rtl($stored, 'kb-reimport detect');
8000 + if (is_string($logical) && $logical !== $stored) {
8001 + $reasons[] = 'RTL';
8002 + }
8003 + }
8004 + return $reasons;
8005 +}
8006 +
8007 +/** A product price line written before 7403ec has no currency code; one written since has. */
8008 +private function mxchat_reimport_price_code_missing($stored, $fresh) {
8009 + $coded = '/^(?:Price|Sale Price|Price Range): [A-Z]{3} /m';
8010 + return (bool) preg_match('/^(?:Price|Sale Price|Price Range): /m', $stored)
8011 + && !preg_match($coded, $stored)
8012 + && (bool) preg_match($coded, $fresh);
8013 +}
8014 +
8015 +/** What submit_content_to_db() does to text before it stores it. */
8016 +private function mxchat_reimport_storable($text) {
8017 + $text = wp_check_invalid_utf8((string) $text);
8018 + $out = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
8019 + return is_string($out) ? $out : $text;
8020 +}
8021 +
8022 +/**
8023 + * Rebuild an entry's text from its source with the current import chain.
8024 + *
8025 + * @return array{mode:string,texts?:array,title?:string,post_id?:int,note?:string}
8026 + * mode 'inplace' : no source to go back to (manual text, an uploaded file, a
8027 + * PDF page) - only reading order can be repaired, in place.
8028 + * mode 'refetch' : 'texts' holds one rebuilt text per import route that could
8029 + * have written this entry (label => text).
8030 + * mode 'skip' : 'note' says why.
8031 + */
8032 +private function mxchat_reimport_fresh(array $entry, $bot_id) {
8033 + $url = (string) $entry['source_url'];
8034 + $type = (string) $entry['content_type'];
8035 +
8036 + if (!preg_match('#^https?://#i', $url) || $this->mxchat_reimport_is_pdf($entry)) {
8037 + return array('mode' => 'inplace');
8038 + }
8039 + // The WordPress store files any mxchat.ai address as manual text and would
8040 + // add a second row instead of updating this one, so treat it as manual here too.
8041 + if ($entry['store'] === 'wordpress' && strpos($url, 'mxchat.ai') !== false) {
8042 + return array('mode' => 'inplace');
8043 + }
8044 + if ($type === 'youtube') {
8045 + return array('mode' => 'skip', 'note' => __('a video entry, not an imported page', 'mxchat'), 'quiet' => true);
8046 + }
8047 +
8048 + // Imported by address (URL or sitemap import): fetch the page the way the importer does.
8049 + if ($type === 'url' || $type === 'product') {
8050 + $texts = array();
8051 + if (class_exists('WooCommerce') && (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false)) {
8052 + $product_text = $this->mxchat_extract_woocommerce_product_content($url);
8053 + if (!empty($product_text)) {
8054 + $texts['product'] = $product_text;
8055 + }
8056 + }
8057 + if ($type === 'url' || empty($texts)) {
8058 + $response = wp_remote_get($url, array(
8059 + 'timeout' => 30,
8060 + 'redirection' => 5,
8061 + 'user-agent' => function_exists('mxchat_ingest_user_agent') ? mxchat_ingest_user_agent() : 'MxChat',
8062 + ));
8063 + if (is_wp_error($response)) {
8064 + if (empty($texts)) {
8065 + return array('mode' => 'skip', 'note' => sprintf(__('the page could not be fetched (%s)', 'mxchat'), $response->get_error_message()));
8066 + }
8067 + } else {
8068 + $code = (int) wp_remote_retrieve_response_code($response);
8069 + if ($code !== 200) {
8070 + if (empty($texts)) {
8071 + return array('mode' => 'skip', 'note' => sprintf(__('the page is gone (HTTP %d)', 'mxchat'), $code));
8072 + }
8073 + } elseif ($this->mxchat_is_pdf_url($url, $response)) {
8074 + return array('mode' => 'inplace');
8075 + } else {
8076 + $html = wp_remote_retrieve_body($response);
8077 + $page = $this->mxchat_sanitize_content_for_api($this->mxchat_extract_main_content($html));
8078 + if ($page !== '') {
8079 + $texts['page'] = $page;
8080 + $title = MxChat_Utils::extract_page_title($html);
8081 + }
8082 + }
8083 + }
8084 + }
8085 + if ($type === 'product' && !isset($texts['bulk'])) {
8086 + $bulk = $this->mxchat_reimport_post_text($url, $bot_id, 'bulk');
8087 + if (is_array($bulk)) {
8088 + $texts['bulk'] = $bulk['text'];
8089 + }
8090 + }
8091 + if (empty($texts)) {
8092 + return array('mode' => 'skip', 'note' => __('the page has no readable text now', 'mxchat'));
8093 + }
8094 + return array('mode' => 'refetch', 'texts' => $texts, 'title' => isset($title) ? (string) $title : '');
8095 + }
8096 +
8097 + // Everything else was built from a post, a product or a media file on this site.
8098 + $post_id = (int) $this->mxchat_url_to_post_id_improved($url);
8099 + $post = $post_id ? get_post($post_id) : null;
8100 + if (!$post) {
8101 + if ($type === 'content' || $type === '') {
8102 + // Text an owner typed and gave an address to: there is no source to rebuild it from.
8103 + return array('mode' => 'inplace');
8104 + }
8105 + return array('mode' => 'skip', 'note' => __('its source is gone (no post or file on this site has this address)', 'mxchat'));
8106 + }
8107 + if ($post->post_type === 'attachment') {
8108 + $extracted = $this->mxchat_extract_attachment_text($post_id);
8109 + if ($extracted['text'] === '') {
8110 + return array('mode' => 'skip', 'note' => sprintf(__('the file could not be read (%s)', 'mxchat'), $extracted['reason']));
8111 + }
8112 + return array(
8113 + 'mode' => 'refetch',
8114 + 'texts' => array('file' => $this->mxchat_build_attachment_content($post, $extracted['text'])),
8115 + 'post_id' => $post_id,
8116 + 'is_file' => true,
8117 + );
8118 + }
8119 + if ($post->post_status !== 'publish') {
8120 + return array('mode' => 'skip', 'note' => __('its source is no longer published', 'mxchat'));
8121 + }
8122 +
8123 + $texts = array();
8124 + $routes = ($type === 'content' || $type === '') ? array('sync', 'bulk') : array('bulk', 'sync');
8125 + foreach ($routes as $route) {
8126 + $built = $this->mxchat_reimport_post_text($url, $bot_id, $route, $post);
8127 + if (is_array($built)) {
8128 + $texts[$route] = $built['text'];
8129 + }
8130 + }
8131 + if ($post->post_type === 'product' && class_exists('WooCommerce') && function_exists('wc_get_product')) {
8132 + $product = wc_get_product($post_id);
8133 + if ($product) {
8134 + $from_product = array('product' => $this->mxchat_prepare_product_content_for_indexing($product));
8135 + // With the WooCommerce integration on, the product writer owns auto-synced product entries.
8136 + $owns = isset($this->options['enable_woocommerce_integration']) && in_array($this->options['enable_woocommerce_integration'], array('1', 'on'), true);
8137 + $texts = $owns ? $from_product + $texts : $texts + $from_product;
8138 + }
8139 + }
8140 + if (empty($texts)) {
8141 + return array('mode' => 'skip', 'note' => __('its source has no text now', 'mxchat'));
8142 + }
8143 + return array('mode' => 'refetch', 'texts' => $texts, 'post_id' => $post_id);
8144 +}
8145 +
8146 +/** A post's text as the bulk import ('bulk') or auto-sync ('sync') builds it. @return array|null */
8147 +private function mxchat_reimport_post_text($url, $bot_id, $route, $post = null) {
8148 + if (!$post) {
8149 + $post_id = (int) $this->mxchat_url_to_post_id_improved($url);
8150 + $post = $post_id ? get_post($post_id) : null;
8151 + }
8152 + if (!$post || $post->post_status !== 'publish' || $post->post_type === 'attachment') {
8153 + return null;
8154 + }
8155 + $post_id = (int) $post->ID;
8156 + $filtered = apply_filters('mxchat_before_process_post', $post, $route === 'sync' ? 'default' : $bot_id);
8157 + if (!($filtered instanceof WP_Post)) {
8158 + $filtered = $post;
8159 + }
8160 + $args = ($route === 'sync')
8161 + ? array('read_display' => true, 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1', 'include_product_tabs' => false)
8162 + : array('read_display' => false, 'extract_acf_pdfs' => get_option('mxchat_acf_pdf_extraction', '0') === '1', 'include_product_tabs' => true);
8163 + $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $filtered, $args);
8164 + return array('text' => (string) $prepared['content']);
8165 +}
8166 +
8167 +/**
8168 + * Decide what to do with one entry. Makes no embedding call and writes nothing.
8169 + *
8170 + * @return array{outcome:string,reasons:array,note:string,mode:string,fresh:string,title:string,post_id:int,is_file:bool}
8171 + * outcome: repair | unchanged | changed | skipped
8172 + */
8173 +private function mxchat_reimport_evaluate(array $entry, $bot_id) {
8174 + $result = array('outcome' => 'unchanged', 'reasons' => array(), 'note' => '', 'mode' => 'refetch', 'fresh' => '', 'title' => '', 'post_id' => 0, 'is_file' => false, 'quiet' => false);
8175 + $is_pdf = $this->mxchat_reimport_from_pdf($entry);
8176 +
8177 + $fresh = $this->mxchat_reimport_fresh($entry, $bot_id);
8178 + $result['mode'] = $fresh['mode'];
8179 + if ($fresh['mode'] === 'skip') {
8180 + $result['outcome'] = 'skipped';
8181 + $result['note'] = $fresh['note'];
8182 + $result['quiet'] = !empty($fresh['quiet']);
8183 + return $result;
8184 + }
8185 +
8186 + if ($fresh['mode'] === 'inplace') {
8187 + // Reading order is the one defect that can be repaired without the source.
8188 + if (!$this->mxchat_reimport_is_pdf($entry) && !$this->mxchat_reimport_has_pdf_header($entry)) {
8189 + return $result;
8190 + }
8191 + $rows = $this->mxchat_reimport_inplace_rows($entry, $bot_id);
8192 + if (is_wp_error($rows)) {
8193 + $result['outcome'] = 'skipped';
8194 + $result['note'] = $rows->get_error_message();
8195 + return $result;
8196 + }
8197 + foreach ($rows as $row) {
8198 + if ($row['new'] !== $row['old']) {
8199 + $result['outcome'] = 'repair';
8200 + $result['reasons'] = array('RTL');
8201 + return $result;
8202 + }
8203 + }
8204 + return $result;
8205 + }
8206 +
8207 + $stored = $this->mxchat_reimport_stored_text($entry, $bot_id);
8208 + if (is_wp_error($stored)) {
8209 + $result['outcome'] = 'skipped';
8210 + $result['note'] = $stored->get_error_message();
8211 + return $result;
8212 + }
8213 + $stored_squash = $this->mxchat_reimport_squash($stored);
8214 + $stored_norm = $this->mxchat_reimport_norm($stored, $is_pdf);
8215 + $stored_title = $this->mxchat_reimport_norm(strtok($stored, "\n"), false);
8216 +
8217 + // More than one import route can have written an entry (a product is built
8218 + // one way by auto-sync and another by the bulk import). Take the route whose
8219 + // text shares the most lines with what is stored.
8220 + $stored_lines = array_flip(array_filter(array_map('trim', explode("
8221 +", $stored)), 'strlen'));
8222 + $repair = null;
8223 + $best = -1;
8224 + foreach ($fresh['texts'] as $route => $text) {
8225 + $text = $this->mxchat_reimport_storable($text);
8226 + if ($this->mxchat_reimport_squash($text) === $stored_squash) {
8227 + return $result; // identical to what one import route builds today
8228 + }
8229 + $reasons = $this->mxchat_reimport_reasons($stored, $text, $is_pdf);
8230 + $same_doc = ($this->mxchat_reimport_norm($text, $is_pdf) === $stored_norm);
8231 + if (!$same_doc && $route !== 'page' && $this->mxchat_reimport_price_code_missing($stored, $text)
8232 + && $this->mxchat_reimport_norm(strtok($text, "\n"), false) === $stored_title) {
8233 + // A product indexed before prices carried their currency code.
8234 + $same_doc = true;
8235 + if (!in_array('currency', $reasons, true)) {
8236 + $reasons[] = 'currency';
8237 + }
8238 + }
8239 + if ($same_doc && !empty($reasons)) {
8240 + $shared = count(array_intersect_key($stored_lines, array_flip(array_filter(array_map('trim', explode("
8241 +", $text)), 'strlen'))));
8242 + if ($shared > $best) {
8243 + $best = $shared;
8244 + $repair = array('reasons' => $reasons, 'fresh' => $text);
8245 + }
8246 + }
8247 + }
8248 +
8249 + if ($repair === null) {
8250 + $result['outcome'] = 'changed';
8251 + $result['note'] = __('its source reads differently now, and not because of these fixes', 'mxchat');
8252 + return $result;
8253 + }
8254 + $result['outcome'] = 'repair';
8255 + $result['reasons'] = $repair['reasons'];
8256 + $result['fresh'] = $repair['fresh'];
8257 + $result['title'] = isset($fresh['title']) ? (string) $fresh['title'] : '';
8258 + $result['post_id'] = isset($fresh['post_id']) ? (int) $fresh['post_id'] : 0;
8259 + $result['is_file'] = !empty($fresh['is_file']);
8260 + return $result;
8261 +}
8262 +
8263 +/** An uploaded PDF page stored before 3.2.24 has a manual identity; its header is what marks it as a PDF. */
8264 +private function mxchat_reimport_has_pdf_header(array $entry) {
8265 + if ($entry['store'] !== 'wordpress') {
8266 + return false;
8267 + }
8268 + global $wpdb;
8269 + $head = (string) $wpdb->get_var($wpdb->prepare(
8270 + "SELECT LEFT(article_content, 400) FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
8271 + (int) $entry['id']
8272 + ));
8273 + return $head !== '' && $head[0] === '{' && stripos($head, 'pdf') !== false && strpos($head, "\n---\n") !== false;
8274 +}
8275 +
8276 +/**
8277 + * The stored pieces of an entry with reading order restored, piece by piece.
8278 + * WordPress rows keep their metadata header byte for byte (as rtl-repair does).
8279 + * @return array[]|WP_Error each: id, old, new
8280 + */
8281 +private function mxchat_reimport_inplace_rows(array $entry, $bot_id) {
8282 + $out = array();
8283 + if ($entry['store'] === 'wordpress') {
8284 + global $wpdb;
8285 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
8286 + $rows = ($entry['source_url'] !== '')
8287 + ? $wpdb->get_results($wpdb->prepare("SELECT id, article_content FROM {$table} WHERE source_url = %s ORDER BY id ASC", $entry['source_url']))
8288 + : $wpdb->get_results($wpdb->prepare("SELECT id, article_content FROM {$table} WHERE id = %d", (int) $entry['id']));
8289 + foreach ((array) $rows as $row) {
8290 + list($header, $text) = $this->mxchat_rtl_repair_split((string) $row->article_content);
8291 + $logical = MxChat_Utils::normalize_pdf_rtl($text, 'kb-reimport row ' . $row->id);
8292 + $out[] = array(
8293 + 'id' => (int) $row->id,
8294 + 'old' => (string) $row->article_content,
8295 + 'new' => $header . (is_string($logical) ? $logical : $text),
8296 + );
8297 + }
8298 + return $out;
8299 + }
8300 + $stored = $this->mxchat_reimport_stored_text($entry, $bot_id);
8301 + if (is_wp_error($stored)) {
8302 + return $stored;
8303 + }
8304 + $logical = MxChat_Utils::normalize_pdf_rtl($stored, 'kb-reimport ' . $entry['id']);
8305 + $out[] = array('id' => (string) $entry['id'], 'old' => $stored, 'new' => is_string($logical) ? $logical : $stored);
8306 + return $out;
8307 +}
8308 +
8309 +/** The role an entry is restricted to, read before a rewrite so it can be put back. */
8310 +private function mxchat_reimport_role(array $entry) {
8311 + global $wpdb;
8312 + if ($entry['store'] === 'wordpress') {
8313 + $role = ($entry['source_url'] !== '')
8314 + ? $wpdb->get_var($wpdb->prepare("SELECT role_restriction FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE source_url = %s AND role_restriction IS NOT NULL AND role_restriction <> '' AND role_restriction <> 'public' LIMIT 1", $entry['source_url']))
8315 + : $wpdb->get_var($wpdb->prepare("SELECT role_restriction FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d", (int) $entry['id']));
8316 + return ($role === null || $role === '') ? 'public' : (string) $role;
8317 + }
8318 + $base = preg_replace('/_chunk_\d+$/', '', (string) $entry['id']);
8319 + $table = $wpdb->prefix . 'mxchat_pinecone_roles';
8320 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
8321 + return 'public';
8322 + }
8323 + $role = $wpdb->get_var($wpdb->prepare(
8324 + "SELECT role_restriction FROM {$table} WHERE (vector_id = %s OR vector_id LIKE %s) AND role_restriction <> 'public' LIMIT 1",
8325 + $base, $wpdb->esc_like($base . '_chunk_') . '%'
8326 + ));
8327 + return ($role === null || $role === '') ? 'public' : (string) $role;
8328 +}
8329 +
8330 +/** Put a restriction back on every piece of a rewritten entry. */
8331 +private function mxchat_reimport_restore_role(array $entry, $role, $bot_id) {
8332 + if ($role === 'public' || $entry['source_url'] === '') {
8333 + return;
8334 + }
8335 + global $wpdb;
8336 + if ($entry['store'] === 'wordpress') {
8337 + $wpdb->update($wpdb->prefix . 'mxchat_system_prompt_content', array('role_restriction' => $role), array('source_url' => $entry['source_url']), array('%s'), array('%s'));
8338 + if (class_exists('MxChat_Vectorstore_Manager')) {
8339 + MxChat_Vectorstore_Manager::handle_role_change($entry['source_url'], $bot_id, $role);
8340 + }
8341 + return;
8342 + }
8343 + $base = md5($entry['source_url']);
8344 + $ids = array($base);
8345 + $n = MxChat_Utils::count_chunks_for_url($entry['source_url'], $bot_id);
8346 + for ($i = 0; $i < (int) $n; $i++) {
8347 + $ids[] = MxChat_Chunker::generate_chunk_vector_id($entry['source_url'], $i);
8348 + }
8349 + foreach (array_unique($ids) as $vector_id) {
8350 + $wpdb->replace($wpdb->prefix . 'mxchat_pinecone_roles', array(
8351 + 'vector_id' => $vector_id,
8352 + 'role_restriction' => $role,
8353 + 'updated_at' => current_time('mysql'),
8354 + ), array('%s', '%s', '%s'));
8355 + }
8356 +}
8357 +
8358 +/**
8359 + * Rewrite one entry. Re-evaluates first, so a second run (or a source that
8360 + * changed between the scan and the confirm) cannot write something the scan
8361 + * did not show.
8362 + * @return array{outcome:string,reasons:array,note:string}
8363 + */
8364 +private function mxchat_reimport_apply_entry(array $entry, $bot_id, $api_key) {
8365 + $eval = $this->mxchat_reimport_evaluate($entry, $bot_id);
8366 + if ($eval['outcome'] !== 'repair') {
8367 + return array('outcome' => $eval['outcome'], 'reasons' => array(), 'note' => $eval['note']);
8368 + }
8369 + $role = $this->mxchat_reimport_role($entry);
8370 +
8371 + if ($eval['mode'] === 'inplace') {
8372 + $rows = $this->mxchat_reimport_inplace_rows($entry, $bot_id);
8373 + if (is_wp_error($rows)) {
8374 + return array('outcome' => 'failed', 'reasons' => $eval['reasons'], 'note' => $rows->get_error_message());
8375 + }
8376 + if ($entry['store'] === 'wordpress') {
8377 + global $wpdb;
8378 + foreach ($rows as $row) {
8379 + if ($row['new'] === $row['old']) {
8380 + continue;
8381 + }
8382 + $vector = $this->mxchat_reimport_with_backoff(function () use ($row, $api_key, $bot_id) {
8383 + return MxChat_Utils::regenerate_embedding($row['new'], $api_key, $bot_id);
8384 + });
8385 + if (!is_array($vector)) {
8386 + // Text and vector stay consistent: no repaired text beside a stale vector.
8387 + return array('outcome' => 'failed', 'reasons' => $eval['reasons'], 'note' => is_wp_error($vector) ? $vector->get_error_message() : __('the embedding request failed', 'mxchat'));
8388 + }
8389 + $wpdb->update(
8390 + $wpdb->prefix . 'mxchat_system_prompt_content',
8391 + array('article_content' => $row['new'], 'embedding_vector' => maybe_serialize($vector)),
8392 + array('id' => $row['id']),
8393 + array('%s', '%s'),
8394 + array('%d')
8395 + );
8396 + }
8397 + return array('outcome' => 'repaired', 'reasons' => $eval['reasons'], 'note' => '');
8398 + }
8399 + if ($entry['source_url'] === '') {
8400 + // A Pinecone record with no source has no stable id to write back to.
8401 + return array('outcome' => 'skipped', 'reasons' => array(), 'note' => __('a manual Pinecone entry: open it on the Knowledge screen and save it to repair it', 'mxchat'));
8402 + }
8403 + $eval['fresh'] = $rows[0]['new'];
8404 + }
8405 +
8406 + $source_url = $entry['source_url'];
8407 + $type = $entry['content_type'] !== '' ? $entry['content_type'] : 'content';
8408 + $fresh = $eval['fresh'];
8409 + $title = $eval['title'];
8410 + $result = $this->mxchat_reimport_with_backoff(function () use ($fresh, $source_url, $api_key, $bot_id, $type, $title) {
8411 + return MxChat_Utils::submit_content_to_db($fresh, $source_url, $api_key, md5($source_url), $bot_id, $type, $title);
8412 + });
8413 + if ($result !== true) {
8414 + return array('outcome' => 'failed', 'reasons' => $eval['reasons'], 'note' => is_wp_error($result) ? $result->get_error_message() : __('the entry could not be stored', 'mxchat'));
8415 + }
8416 + $this->mxchat_reimport_restore_role($entry, $role, $bot_id);
8417 + if ($eval['is_file'] && $eval['post_id']) {
8418 + $this->mxchat_remember_attachment_signature($eval['post_id'], $source_url, $fresh);
8419 + }
8420 + return array('outcome' => 'repaired', 'reasons' => $eval['reasons'], 'note' => '');
8421 +}
8422 +
8423 +/** Run a write; on a rate-limit answer wait and try again, twice at most. */
8424 +private function mxchat_reimport_with_backoff(callable $call) {
8425 + $result = null;
8426 + for ($attempt = 0; $attempt < 3; $attempt++) {
8427 + $result = $call();
8428 + if (!is_wp_error($result) || !preg_match('/\b429\b|rate.?limit|too many requests/i', $result->get_error_message())) {
8429 + return $result;
8430 + }
8431 + if ($attempt < 2) {
8432 + sleep($attempt === 0 ? 2 : 5);
8433 + }
8434 + }
8435 + return $result;
8436 +}
8437 +
8438 +private function mxchat_reimport_label(array $entry) {
8439 + if ($entry['source_url'] !== '' && strpos($entry['source_url'], 'mxchat://') !== 0) {
8440 + return $entry['source_url'];
8441 + }
8442 + return sprintf(__('Manual entry %s', 'mxchat'), $entry['id']);
8443 +}
8444 +
8445 +private function mxchat_reimport_note(array &$state, $kind, array $entry, $note) {
8446 + if ($state['cap'] > 0 && count($state['notes']) >= $state['cap']) {
8447 + return;
8448 + }
8449 + $state['notes'][] = array('kind' => $kind, 'label' => $this->mxchat_reimport_label($entry), 'note' => $note);
8450 +}
8451 +
8452 +/** Scan until the deadline: sort entries into repair / unchanged / changed / skipped. */
8453 +private function mxchat_reimport_scan_step(array &$state, $deadline) {
8454 + while (!$state['scan_done'] && microtime(true) < $deadline) {
8455 + $page = $this->mxchat_reimport_list_page($state, 10);
8456 + if (is_wp_error($page)) {
8457 + $state['error'] = $page->get_error_message();
8458 + $state['scan_done'] = true;
8459 + break;
8460 + }
8461 + foreach ($page['entries'] as $entry) {
8462 + if (!$this->mxchat_reimport_selected($entry, $state)) {
8463 + continue;
8464 + }
8465 + $state['scanned']++;
8466 + $eval = $this->mxchat_reimport_evaluate($entry, $state['bot']);
8467 + if ($eval['outcome'] === 'repair') {
8468 + unset($entry['text']);
8469 + $entry['reasons'] = $eval['reasons'];
8470 + $entry['label'] = $this->mxchat_reimport_label($entry);
8471 + $state['candidates'][] = $entry;
8472 + } elseif ($eval['outcome'] === 'unchanged') {
8473 + $state['unchanged']++;
8474 + } elseif ($eval['outcome'] === 'changed') {
8475 + $state['changed']++;
8476 + $this->mxchat_reimport_note($state, 'changed', $entry, $eval['note']);
8477 + } elseif (!empty($eval['quiet'])) {
8478 + $state['unchanged']++;
8479 + } else {
8480 + $state['skipped']++;
8481 + $this->mxchat_reimport_note($state, 'skipped', $entry, $eval['note']);
8482 + }
8483 + }
8484 + if ($page['done']) {
8485 + $state['scan_done'] = true;
8486 + }
8487 + }
8488 + if ($state['scan_done'] && $state['phase'] === 'scan') {
8489 + $state['phase'] = 'scanned';
8490 + }
8491 + $state['updated'] = time();
8492 +}
8493 +
8494 +/** Rewrite scanned candidates until the deadline (or $limit repairs). */
8495 +private function mxchat_reimport_apply_step(array &$state, $deadline, $api_key, $sleep_ms = 0, $limit = 0) {
8496 + $total = count($state['candidates']);
8497 + while ($state['pos'] < $total && microtime(true) < $deadline) {
8498 + if ($limit > 0 && $state['repaired'] >= $limit) {
8499 + break;
8500 + }
8501 + $i = $state['pos'];
8502 + $entry = $state['candidates'][$i];
8503 + $done = $this->mxchat_reimport_apply_entry($entry, $state['bot'], $api_key);
8504 + $state['candidates'][$i]['result'] = $done['outcome'];
8505 + $state['candidates'][$i]['note'] = $done['note'];
8506 + if ($done['outcome'] === 'repaired') {
8507 + $state['repaired']++;
8508 + if ($sleep_ms > 0) {
8509 + usleep($sleep_ms * 1000);
8510 + }
8511 + } elseif ($done['outcome'] === 'failed') {
8512 + $state['failed']++;
8513 + }
8514 + $state['pos']++;
8515 + }
8516 + if ($state['pos'] >= $total || ($limit > 0 && $state['repaired'] >= $limit)) {
8517 + $state['phase'] = 'done';
8518 + }
8519 + $state['updated'] = time();
8520 +}
8521 +
8522 +/** Embedding key for a bot, or WP_Error with the reason in plain words. */
8523 +private function mxchat_reimport_api_key($bot_id) {
8524 + $bot_options = $this->get_bot_options($bot_id);
8525 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
8526 + $preflight = MxChat_Utils::embedding_preflight($options);
8527 + if (!$preflight['ok']) {
8528 + return new WP_Error('embedding_config', $preflight['reason']);
8529 + }
8530 + return $preflight['api_key'];
8531 +}
8532 +
8533 +/**
8534 + * WP-CLI: re-import knowledge entries that were damaged by an import defect
8535 + * fixed in a later release (HTML entities left in the text, currency signs,
8536 + * accents and other marks removed, joiner characters turned into spaces,
8537 + * right-to-left PDF text stored backwards).
8538 + *
8539 + * Each entry's text is rebuilt from its source with the current importer and
8540 + * compared with what is stored. An entry is rewritten and re-embedded only
8541 + * when the difference is one of those defects. An entry whose source has
8542 + * simply changed since it was imported is listed and left alone, and so is
8543 + * an entry whose source is gone. Nothing is ever deleted.
8544 + *
8545 + * ## OPTIONS
8546 + *
8547 + * [--dry-run]
8548 + * : List what would be rewritten, with the reason, without changing anything.
8549 + * No embedding request is made.
8550 + *
8551 + * [--source=<source>]
8552 + * : Which entries to check: all (default), upload, manual, or the start of an
8553 + * address (for example https://example.com/docs/).
8554 + *
8555 + * [--before=<date>]
8556 + * : Only entries last written before this date (YYYY-MM-DD). Default
8557 + * 2026-08-28, the release date of the last of these fixes.
8558 + *
8559 + * [--bot=<id>]
8560 + * : The bot whose knowledge base and embedding settings to use. Default: default.
8561 + *
8562 + * [--limit=<n>]
8563 + * : Rewrite at most this many entries, then stop.
8564 + *
8565 + * [--sleep=<ms>]
8566 + * : Pause this many milliseconds after each rewritten entry.
8567 + *
8568 + * [--yes]
8569 + * : Proceed when more than 25 entries need re-embedding (API cost gate).
8570 + *
8571 + * ## EXAMPLES
8572 + *
8573 + * wp mxchat kb-reimport --dry-run
8574 + * wp mxchat kb-reimport --source=https://example.com/docs/ --limit=50
8575 + * wp mxchat kb-reimport --before=2026-10-01 --yes
8576 + */
8577 +public function cli_kb_reimport($args, $assoc_args) {
8578 + $dry_run = !empty($assoc_args['dry-run']);
8579 + $yes = !empty($assoc_args['yes']);
8580 + $limit = isset($assoc_args['limit']) ? max(0, (int) $assoc_args['limit']) : 0;
8581 + $sleep = isset($assoc_args['sleep']) ? max(0, (int) $assoc_args['sleep']) : 0;
8582 + if (isset($assoc_args['before']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $assoc_args['before'])) {
8583 + WP_CLI::error('--before takes a date as YYYY-MM-DD.');
8584 + }
8585 +
8586 + $state = $this->mxchat_reimport_new_state(array(
8587 + 'bot' => isset($assoc_args['bot']) ? $assoc_args['bot'] : 'default',
8588 + 'source' => isset($assoc_args['source']) ? $assoc_args['source'] : 'all',
8589 + 'before' => isset($assoc_args['before']) ? $assoc_args['before'] : self::REIMPORT_DEFAULT_BEFORE,
8590 + 'cap' => 0,
8591 + ));
8592 + $stores = array('wordpress' => 'the WordPress database', 'pinecone' => 'Pinecone', 'documents' => 'a Pinecone document index');
8593 + WP_CLI::log(sprintf('Checking entries in %s last written before %s (source: %s).', $stores[$state['store']], $state['before'], $state['source']));
8594 +
8595 + while (!$state['scan_done']) {
8596 + $this->mxchat_reimport_scan_step($state, microtime(true) + 20);
8597 + }
8598 + if ($state['error'] !== '') {
8599 + WP_CLI::error('The scan stopped: ' . $state['error'] . ' Nothing was changed.');
8600 + }
8601 +
8602 + foreach ($state['notes'] as $note) {
8603 + WP_CLI::log(sprintf('%s %s (%s)', $note['kind'] === 'changed' ? 'Left alone' : 'Skipped', $note['label'], $note['note']));
8604 + }
8605 + foreach ($state['candidates'] as $c) {
8606 + WP_CLI::log(sprintf('%s %s (%s)', $dry_run ? 'Would rewrite' : 'Will rewrite', $c['label'], implode(', ', $c['reasons'])));
8607 + }
8608 + $count = count($state['candidates']);
8609 + $summary = sprintf('%d checked: %d to rewrite, %d already correct, %d left alone (source changed), %d skipped.', $state['scanned'], $count, $state['unchanged'], $state['changed'], $state['skipped']);
8610 + if ($dry_run) {
8611 + WP_CLI::success('Dry run. ' . $summary . ' Nothing was changed.');
8612 + return;
8613 + }
8614 + if ($count === 0) {
8615 + WP_CLI::success($summary . ' Nothing to rewrite.');
8616 + return;
8617 + }
8618 + $planned = ($limit > 0) ? min($limit, $count) : $count;
8619 + if ($planned > 25 && !$yes) {
8620 + WP_CLI::error(sprintf('%d entries need re-embedding (more than 25). Re-run with --yes to confirm the API cost, or with --limit. Nothing was changed.', $planned));
8621 + }
8622 + $api_key = $this->mxchat_reimport_api_key($state['bot']);
8623 + if (is_wp_error($api_key)) {
8624 + WP_CLI::error('Embedding configuration problem: ' . $api_key->get_error_message());
8625 + }
8626 +
8627 + $state['phase'] = 'apply';
8628 + $shown = 0;
8629 + while ($state['phase'] === 'apply') {
8630 + $this->mxchat_reimport_apply_step($state, microtime(true) + 20, $api_key, $sleep, $limit);
8631 + for (; $shown < $state['pos']; $shown++) {
8632 + $c = $state['candidates'][$shown];
8633 + if ($c['result'] === 'repaired') {
8634 + WP_CLI::log(sprintf('Rewritten %s (%s)', $c['label'], implode(', ', $c['reasons'])));
8635 + } elseif ($c['result'] === 'failed') {
8636 + WP_CLI::warning(sprintf('Not rewritten %s: %s. Entry left unchanged.', $c['label'], $c['note']));
8637 + } else {
8638 + WP_CLI::log(sprintf('No longer needed %s', $c['label']));
8639 + }
8640 + }
8641 + }
8642 + $remaining = $count - $state['pos'];
8643 + WP_CLI::success(sprintf('Rewrote and re-embedded %d entr%s; %d failed%s. %s', $state['repaired'], $state['repaired'] === 1 ? 'y' : 'ies', $state['failed'], $remaining > 0 ? sprintf('; %d left for the next run', $remaining) : '', $summary));
8644 +}
8645 +
8646 +/** What the Knowledge screen is shown of a state: counts, a capped list, no entry text. */
8647 +private function mxchat_reimport_public_state(array $state) {
8648 + $list = array();
8649 + foreach (array_slice($state['candidates'], 0, 100) as $c) {
8650 + $list[] = array(
8651 + 'label' => $c['label'],
8652 + 'reasons' => $c['reasons'],
8653 + 'result' => isset($c['result']) ? $c['result'] : '',
8654 + 'note' => isset($c['note']) ? $c['note'] : '',
8655 + );
8656 + }
8657 + return array(
8658 + 'phase' => $state['phase'],
8659 + 'before' => $state['before'],
8660 + 'store' => $state['store'],
8661 + 'scanned' => (int) $state['scanned'],
8662 + 'to_repair' => count($state['candidates']),
8663 + 'unchanged' => (int) $state['unchanged'],
8664 + 'changed' => (int) $state['changed'],
8665 + 'skipped' => (int) $state['skipped'],
8666 + 'pos' => (int) $state['pos'],
8667 + 'repaired' => (int) $state['repaired'],
8668 + 'failed' => (int) $state['failed'],
8669 + 'error' => (string) $state['error'],
8670 + 'list' => $list,
8671 + 'notes' => array_slice($state['notes'], 0, 100),
8672 + );
8673 +}
8674 +
8675 +/**
8676 + * AJAX: the Knowledge screen's "Re-import affected entries" action, in
8677 + * resumable steps. op = scan (restart=1 begins a new scan), apply, cancel,
8678 + * status. A scan step and an apply step each run for a few seconds and save
8679 + * where they stopped, so a closed tab or a timeout loses nothing.
8680 + */
8681 +public function ajax_mxchat_kb_reimport() {
8682 + check_ajax_referer('mxchat_kb_reimport', 'nonce');
8683 + if (!current_user_can('manage_options')) {
8684 + wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
8685 + }
8686 + $op = isset($_POST['op']) ? sanitize_key($_POST['op']) : 'status';
8687 + $state = get_option(self::REIMPORT_STATE_OPTION, array());
8688 + if (!is_array($state) || empty($state['phase'])) {
8689 + $state = array();
8690 + }
8691 +
8692 + if ($op === 'cancel') {
8693 + delete_option(self::REIMPORT_STATE_OPTION);
8694 + wp_send_json_success(array('state' => array('phase' => 'idle')));
8695 + }
8696 + if ($op === 'status') {
8697 + wp_send_json_success(array('state' => empty($state) ? array('phase' => 'idle') : $this->mxchat_reimport_public_state($state)));
8698 + }
8699 +
8700 + if ($op === 'scan') {
8701 + if (!empty($_POST['restart']) || empty($state) || $state['phase'] !== 'scan') {
8702 + $state = $this->mxchat_reimport_new_state(array(
8703 + 'bot' => isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default',
8704 + 'before' => isset($_POST['before']) ? sanitize_text_field(wp_unslash($_POST['before'])) : self::REIMPORT_DEFAULT_BEFORE,
8705 + ));
8706 + }
8707 + $this->mxchat_reimport_scan_step($state, microtime(true) + 8);
8708 + update_option(self::REIMPORT_STATE_OPTION, $state, false);
8709 + wp_send_json_success(array('state' => $this->mxchat_reimport_public_state($state)));
8710 + }
8711 +
8712 + if ($op === 'apply') {
8713 + if (empty($state) || !in_array($state['phase'], array('scanned', 'apply'), true)) {
8714 + wp_send_json_error(array('message' => __('Check the knowledge base first.', 'mxchat')));
8715 + }
8716 + $api_key = $this->mxchat_reimport_api_key($state['bot']);
8717 + if (is_wp_error($api_key)) {
8718 + wp_send_json_error(array('message' => $api_key->get_error_message()));
8719 + }
8720 + $state['phase'] = 'apply';
8721 + $this->mxchat_reimport_apply_step($state, microtime(true) + 8, $api_key);
8722 + update_option(self::REIMPORT_STATE_OPTION, $state, false);
8723 + wp_send_json_success(array('state' => $this->mxchat_reimport_public_state($state)));
8724 + }
8725 +
8726 + wp_send_json_error(array('message' => __('Unknown request.', 'mxchat')));
8727 +}
8728 +
8729 +public function mxchat_handle_post_delete($post_id) {
8730 + // Get post data before it's deleted
8731 + $post = get_post($post_id);
8732 +
8733 + // Basic validation
8734 + if (!$post || wp_is_post_revision($post_id)) {
8735 + return;
8736 + }
8737 +
8738 + $post_type = $post->post_type;
8739 +
8740 + // Check if sync is enabled for this post type
8741 + $should_sync = false;
8742 +
8743 + // Check built-in post types first
8744 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
8745 + $should_sync = true;
8746 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
8747 + $should_sync = true;
8748 + } else {
8749 + // Check custom post types
8750 + $option_name = 'mxchat_auto_sync_' . $post_type;
8751 + if (get_option($option_name) === '1') {
8752 + $should_sync = true;
8753 + }
8754 + }
8755 +
8756 + if (!$should_sync) {
8757 + return;
8758 + }
8759 +
8760 + // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
8761 + // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
8762 + // real vector IDs stored under the original URL.
8763 + $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
8764 + if (!$source_url) {
8765 + //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
8766 + return;
8767 + }
8768 +
8769 + // Use chunk-aware deletion (handles both chunked and non-chunked content)
8770 + $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
8771 +
8772 + if (is_wp_error($delete_result)) {
8773 + //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
8774 + }
8775 +
8776 + delete_transient('mxchat_prev_url_' . $post_id);
8777 + delete_transient('mxchat_prev_status_' . $post_id);
8778 +}
8779 +
8780 +/**
8781 + * Resolve the source URL for a post being trashed/deleted.
8782 + *
8783 + * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
8784 + * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
8785 + * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
8786 + * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
8787 + */
8788 +private function mxchat_resolve_pre_trash_url($post_id) {
8789 + $previous_url = get_transient('mxchat_prev_url_' . $post_id);
8790 + if (!empty($previous_url)) {
8791 + return $previous_url;
8792 + }
8793 +
8794 + $current = get_permalink($post_id);
8795 + if (!$current) {
8796 + return '';
8797 + }
8798 + return preg_replace('#__trashed(/?)$#', '$1', $current);
8799 +}
8800 +
8801 +
8802 +
8803 +public function mxchat_handle_product_change($post_id, $post, $update) {
8804 + if ($post->post_type !== 'product') {
8805 + return;
8806 + }
8807 +
8808 + if ($post->post_status === 'publish') {
8809 + add_action('shutdown', function() use ($post_id) {
8810 + $product = wc_get_product($post_id);
8811 + if ($product) {
8812 + $this->mxchat_store_product_embedding($product);
8813 + }
8814 + });
8815 + }
8816 +}
8817 +
8818 +/**
8819 + * Store WooCommerce product embeddings
8820 + */
8821 +private function mxchat_store_product_embedding($product) {
8822 + if (!isset($this->options['enable_woocommerce_integration']) ||
8823 + !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
8824 + return;
8825 + }
8826 +
8827 + $source_url = get_permalink($product->get_id());
8828 + $product_id = $product->get_id();
8829 +
8830 + // Build product content via the shared WC-object assembler (a3d60c) — this
8831 + // writer owns product rows whenever the integration is on.
8832 + $content = $this->mxchat_prepare_product_content_for_indexing($product);
8833 +
8834 + // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return
8835 + // shape preserved.
8836 + $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
8837 + if (!$preflight['ok']) {
8838 + //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']);
8839 + return;
8840 + }
8841 + $api_key = $preflight['api_key'];
8842 +
8843 + // Use the centralized utility function for storage
8844 + $result = MxChat_Utils::submit_content_to_db(
8845 + $content,
8846 + $source_url,
8847 + $api_key,
8848 + md5($source_url) // Vector ID for Pinecone
8849 + );
8850 +
8851 + // After successful storage, apply role restriction based on tags
8852 + if (!is_wp_error($result)) {
8853 + $this->apply_role_restriction_to_post($product_id, $source_url);
8854 + }
8855 +
8856 + if (is_wp_error($result)) {
8857 + //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
8858 + }
8859 +}
8860 +
8861 +public function mxchat_handle_product_delete($post_id) {
8862 + if (get_post_type($post_id) !== 'product') {
8863 + return;
8864 + }
8865 +
8866 + $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
8867 + if (!$source_url) {
8868 + return;
8869 + }
8870 +
8871 + // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
8872 + MxChat_Utils::delete_chunks_for_url($source_url, 'default');
8873 +
8874 + delete_transient('mxchat_prev_url_' . $post_id);
8875 + delete_transient('mxchat_prev_status_' . $post_id);
8876 +}
8877 +
8878 +/**
8879 + * Handle individual Pinecone content deletion
8880 + */
8881 +public function mxchat_handle_pinecone_prompt_delete() {
8882 + // Check permissions
8883 + if (!current_user_can('manage_options')) {
8884 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
8885 + }
8886 +
8887 + // Verify nonce
8888 + if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
8889 + wp_die(esc_html__('Security check failed.', 'mxchat'));
8890 + }
8891 +
8892 + $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
8893 +
8894 + if (empty($vector_id)) {
8895 + set_transient('mxchat_admin_notice_error',
8896 + esc_html__('Invalid vector ID.', 'mxchat'),
8897 + 30
8898 + );
8899 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
8900 + exit;
8901 + }
8902 +
8903 + // Get Pinecone settings
8904 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
8905 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
8906 +
8907 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
8908 + set_transient('mxchat_admin_notice_error',
8909 + esc_html__('Pinecone is not properly configured.', 'mxchat'),
8910 + 30
8911 + );
8912 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
8913 + exit;
8914 + }
8915 +
8916 + // Delete from Pinecone
8917 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
8918 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
8919 + $vector_id,
8920 + $pinecone_options['mxchat_pinecone_api_key'],
8921 + $pinecone_options['mxchat_pinecone_host'],
8922 + $pinecone_options['mxchat_pinecone_namespace'] ?? ''
8923 + );
8924 +
8925 + if ($result['success']) {
8926 + // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6);
8927 + // a chunk vector id reduces to its base entry there.
8928 + if (class_exists('MxChat_Vectorstore_Manager')) {
8929 + MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, 'default');
8930 + }
8931 + MxChat_Utils::forget_attachment_signature_by_key($vector_id, 'default');
8932 + // No cache clearing needed since we removed caching
8933 + set_transient('mxchat_admin_notice_success',
8934 + esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
8935 + 30
8936 + );
8937 + } else {
8938 + set_transient('mxchat_admin_notice_error',
8939 + esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
8940 + 30
8941 + );
8942 + }
8943 +
8944 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
8945 + exit;
8946 +}
8947 +/**
8948 + * Handle individual Pinecone content deletion via AJAX
8949 + */
8950 +public function ajax_mxchat_delete_pinecone_prompt() {
8951 + // Verify nonce and permissions
8952 + if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
8953 + wp_send_json_error('Invalid nonce');
8954 + exit;
8955 + }
8956 +
8957 + if (!current_user_can('manage_options')) {
8958 + wp_send_json_error('Unauthorized access');
8959 + exit;
8960 + }
8961 +
8962 + $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
8963 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
8964 +
8965 + if (empty($vector_id)) {
8966 + wp_send_json_error('Missing vector ID');
8967 + exit;
8968 + }
8969 +
8970 + // Get bot-specific Pinecone settings
8971 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
8972 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
8973 +
8974 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
8975 +
8976 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
8977 + wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
8978 + exit;
8979 + }
8980 +
8981 + // Delete from the correct Pinecone index and namespace
8982 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
8983 + $vector_id,
8984 + $pinecone_options['mxchat_pinecone_api_key'],
8985 + $pinecone_options['mxchat_pinecone_host'],
8986 + $pinecone_options['mxchat_pinecone_namespace'] ?? ''
8987 + );
8988 +
8989 + if ($result['success']) {
8990 + // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6)
8991 + if (class_exists('MxChat_Vectorstore_Manager')) {
8992 + MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, $bot_id);
8993 + }
8994 + // A media entry removed here must lose its auto-sync signature too,
8995 + // or the file is skipped as "unchanged" on every later save (b0ae54).
8996 + MxChat_Utils::forget_attachment_signature_by_key($vector_id, $bot_id);
8997 + // No cache clearing needed since we removed caching
8998 + wp_send_json_success(array(
8999 + 'message' => 'Entry deleted successfully from Pinecone',
9000 + 'vector_id' => $vector_id,
9001 + 'bot_id' => $bot_id
9002 + ));
9003 + } else {
9004 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
9005 + wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
9006 + }
9007 +
9008 + exit;
9009 +}
9010 +
9011 +/**
9012 + * Handle deletion of all chunks for a given source URL via AJAX
9013 + * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
9014 + */
9015 +public function ajax_mxchat_delete_chunks_by_url() {
9016 + // Verify nonce and permissions
9017 + if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
9018 + wp_send_json_error('Invalid nonce');
9019 + exit;
9020 + }
9021 +
9022 + if (!current_user_can('manage_options')) {
9023 + wp_send_json_error('Unauthorized access');
9024 + exit;
9025 + }
9026 +
9027 + // Identity-preserving (plan dcc19d): esc_url_raw() empties upload:// and
9028 + // mxchat:// identities, so a chunked uploaded file or manual entry could
9029 + // not be deleted from its group row ("Missing source URL").
9030 + $source_url = $this->sanitize_entry_source_url(isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '');
9031 + $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
9032 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
9033 +
9034 + if (empty($source_url)) {
9035 + wp_send_json_error('Missing source URL');
9036 + exit;
9037 + }
9038 +
9039 + // Generate the base vector ID from the source URL (same as how chunks are created)
9040 + $base_vector_id = md5($source_url);
9041 +
9042 + if ($data_source === 'pinecone') {
9043 + // Get bot-specific Pinecone settings (same as working delete function)
9044 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
9045 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
9046 +
9047 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9048 +
9049 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
9050 + wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
9051 + exit;
9052 + }
9053 +
9054 + $api_key = $pinecone_options['mxchat_pinecone_api_key'];
9055 + $host = $pinecone_options['mxchat_pinecone_host'];
9056 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
9057 +
9058 + // Document index (plan 362c31): base id + chunk ids through the documents API.
9059 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
9060 + $docs_cfg = MxChat_Pinecone_Documents::cfg_from_options($pinecone_options);
9061 + $doc_ids = array_merge(array($base_vector_id), MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $docs_cfg));
9062 + $doc_result = MxChat_Pinecone_Documents::delete_ids($doc_ids, $docs_cfg);
9063 + if (is_wp_error($doc_result)) {
9064 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete documents from Pinecone: ' . $doc_result->get_error_message(), array('source_url' => $source_url));
9065 + wp_send_json_error('Failed to delete from Pinecone: ' . $doc_result->get_error_message());
9066 + exit;
9067 + }
9068 + if (class_exists('MxChat_Vectorstore_Manager')) {
9069 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9070 + }
9071 + MxChat_Utils::forget_attachment_signature($source_url, $bot_id);
9072 + wp_send_json_success(array(
9073 + 'message' => 'All chunks deleted successfully from Pinecone',
9074 + 'source_url' => $source_url,
9075 + 'deleted_count' => count($doc_ids)
9076 + ));
9077 + exit;
9078 + }
9079 +
9080 + // Collect all vector IDs to delete
9081 + $vectors_to_delete = array();
9082 +
9083 + // Add the original single-vector ID (for non-chunked content)
9084 + $vectors_to_delete[] = $base_vector_id;
9085 +
9086 + // Use Pinecone list API to find all chunk vectors with this prefix
9087 + // NOTE: Pinecone List API is a GET request with query parameters, not POST
9088 + $prefix = $base_vector_id . '_chunk_';
9089 +
9090 + $query_params = array(
9091 + 'prefix' => $prefix,
9092 + 'limit' => 100
9093 + );
9094 +
9095 + if (!empty($namespace)) {
9096 + $query_params['namespace'] = $namespace;
9097 + }
9098 +
9099 + $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
9100 +
9101 + $list_response = wp_remote_get($list_url, array(
9102 + 'headers' => array(
9103 + 'Api-Key' => $api_key,
9104 + 'accept' => 'application/json'
9105 + ),
9106 + 'timeout' => 30
9107 + ));
9108 +
9109 + if (!is_wp_error($list_response)) {
9110 + $list_body_response = wp_remote_retrieve_body($list_response);
9111 + $list_data = json_decode($list_body_response, true);
9112 + if (!empty($list_data['vectors'])) {
9113 + foreach ($list_data['vectors'] as $vector) {
9114 + if (isset($vector['id'])) {
9115 + $vectors_to_delete[] = $vector['id'];
9116 + }
9117 + }
9118 + }
9119 + }
9120 +
9121 + if (empty($vectors_to_delete)) {
9122 + // Entry already gone from Pinecone — still clear any mirrored
9123 + // Vector Store file so it can't outlive the entry (plan 15b5c6).
9124 + if (class_exists('MxChat_Vectorstore_Manager')) {
9125 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9126 + }
9127 + MxChat_Utils::forget_attachment_signature($source_url, $bot_id);
9128 + wp_send_json_success(array(
9129 + 'message' => 'No vectors found to delete',
9130 + 'source_url' => $source_url
9131 + ));
9132 + exit;
9133 + }
9134 +
9135 + // Delete all vectors using the same endpoint as the working function
9136 + $delete_url = "https://{$host}/vectors/delete";
9137 +
9138 + $delete_body = array(
9139 + 'ids' => $vectors_to_delete
9140 + );
9141 +
9142 + if (!empty($namespace)) {
9143 + $delete_body['namespace'] = $namespace;
9144 + }
9145 +
9146 + $delete_response = wp_remote_post($delete_url, array(
9147 + 'headers' => array(
9148 + 'Api-Key' => $api_key,
9149 + 'accept' => 'application/json',
9150 + 'content-type' => 'application/json'
9151 + ),
9152 + 'body' => wp_json_encode($delete_body),
9153 + 'timeout' => 30
9154 + ));
9155 +
9156 + if (is_wp_error($delete_response)) {
9157 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
9158 + wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
9159 + exit;
9160 + }
9161 +
9162 + $response_code = wp_remote_retrieve_response_code($delete_response);
9163 +
9164 + if ($response_code !== 200) {
9165 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
9166 + wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
9167 + exit;
9168 + }
9169 +
9170 + // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
9171 + if (class_exists('MxChat_Vectorstore_Manager')) {
9172 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9173 + }
9174 + MxChat_Utils::forget_attachment_signature($source_url, $bot_id);
9175 +
9176 + wp_send_json_success(array(
9177 + 'message' => 'All chunks deleted successfully from Pinecone',
9178 + 'source_url' => $source_url,
9179 + 'deleted_count' => count($vectors_to_delete)
9180 + ));
9181 +
9182 + } else {
9183 + // WordPress database deletion
9184 + global $wpdb;
9185 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9186 +
9187 + $result = $wpdb->delete(
9188 + $table_name,
9189 + array('source_url' => $source_url),
9190 + array('%s')
9191 + );
9192 +
9193 + if ($result === false) {
9194 + MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
9195 + wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
9196 + exit;
9197 + }
9198 +
9199 + // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
9200 + if (class_exists('MxChat_Vectorstore_Manager')) {
9201 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9202 + }
9203 + // The delete above removed every bot's rows for this URL.
9204 + MxChat_Utils::forget_attachment_signature($source_url, 'default');
9205 +
9206 + wp_send_json_success(array(
9207 + 'message' => 'All chunks deleted successfully from database',
9208 + 'source_url' => $source_url,
9209 + 'deleted_count' => $result
9210 + ));
9211 + }
9212 +
9213 + exit;
9214 +}
9215 +
9216 +/**
9217 + * Handle individual WordPress database content deletion via AJAX
9218 + * Mirrors the Pinecone delete handler but for WordPress database entries
9219 + */
9220 +public function ajax_mxchat_delete_wordpress_prompt() {
9221 + // Verify nonce and permissions
9222 + if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
9223 + wp_send_json_error('Invalid nonce');
9224 + exit;
9225 + }
9226 +
9227 + if (!current_user_can('manage_options')) {
9228 + wp_send_json_error('Unauthorized access');
9229 + exit;
9230 + }
9231 +
9232 + $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
9233 +
9234 + if (empty($entry_id)) {
9235 + wp_send_json_error('Missing entry ID');
9236 + exit;
9237 + }
9238 +
9239 + global $wpdb;
9240 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9241 +
9242 + // Capture the identity BEFORE the row disappears — needed to mirror the
9243 + // change into the Vector Store (plan 15b5c6).
9244 + $source_url = $wpdb->get_var($wpdb->prepare(
9245 + "SELECT source_url FROM {$table_name} WHERE id = %d",
9246 + $entry_id
9247 + ));
9248 +
9249 + // Clear cache for this entry
9250 + wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
9251 +
9252 + // Delete from database
9253 + $result = $wpdb->delete(
9254 + $table_name,
9255 + array('id' => $entry_id),
9256 + array('%d')
9257 + );
9258 +
9259 + if ($result !== false) {
9260 + // Mirror to the Vector Store: if sibling rows remain (this was one
9261 + // chunk of a larger entry) the entry's file is REFRESHED from what's
9262 + // left; if none remain, the file is removed.
9263 + if (!empty($source_url)) {
9264 + $remaining = (int) $wpdb->get_var($wpdb->prepare(
9265 + "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
9266 + $source_url
9267 + ));
9268 + if (class_exists('MxChat_Vectorstore_Manager')) {
9269 + if ($remaining > 0) {
9270 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, '', 'default');
9271 + } else {
9272 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, 'default');
9273 + }
9274 + }
9275 + // Last row of a media entry gone: drop its auto-sync signature (b0ae54).
9276 + if ($remaining === 0) {
9277 + MxChat_Utils::forget_attachment_signature($source_url, 'default');
9278 + }
9279 + }
9280 +
9281 + wp_send_json_success(array(
9282 + 'message' => 'Entry deleted successfully',
9283 + 'entry_id' => $entry_id
9284 + ));
9285 + } else {
9286 + wp_send_json_error('Failed to delete entry from database');
9287 + }
9288 +
9289 + exit;
9290 +}
9291 +
9292 +/**
9293 + * Handle bulk deletion of knowledge entries via AJAX
9294 + * Supports both Pinecone and WordPress database entries
9295 + */
9296 +public function ajax_mxchat_bulk_delete_knowledge() {
9297 + // Verify nonce and permissions
9298 + if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
9299 + wp_send_json_error('Invalid nonce');
9300 + exit;
9301 + }
9302 +
9303 + if (!current_user_can('manage_options')) {
9304 + wp_send_json_error('Unauthorized access');
9305 + exit;
9306 + }
9307 +
9308 + $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
9309 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
9310 +
9311 + if (empty($entries) || !is_array($entries)) {
9312 + wp_send_json_error('No entries provided');
9313 + exit;
9314 + }
9315 +
9316 + // Extend execution time — bulk Pinecone operations can take a while
9317 + if (function_exists('set_time_limit')) {
9318 + set_time_limit(120);
9319 + }
9320 +
9321 + $success_ids = array();
9322 + $failed_ids = array();
9323 + $errors = array();
9324 +
9325 + global $wpdb;
9326 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9327 +
9328 + // Get Pinecone manager for Pinecone deletions
9329 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
9330 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
9331 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9332 +
9333 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
9334 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
9335 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
9336 + // Document index (plan 362c31): list + delete go through the documents API below.
9337 + $bulk_docs = class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options);
9338 + $bulk_docs_cfg = $bulk_docs ? MxChat_Pinecone_Documents::cfg_from_options($pinecone_options) : array();
9339 +
9340 + // =============================================
9341 + // PHASE 1: Collect all Pinecone vector IDs
9342 + // and separate WordPress entries
9343 + // =============================================
9344 + $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
9345 + $wordpress_entries = array(); // entries for WordPress DB deletion
9346 + $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
9347 + $vs_mirror_urls = array(); // Vector Store mirror: URLs to delete (plan 15b5c6)
9348 + $vs_mirror_keys = array(); // Vector Store mirror: bare vector ids to delete
9349 +
9350 + foreach ($entries as $entry) {
9351 + $entry_id = sanitize_text_field($entry['id'] ?? '');
9352 + $source = sanitize_text_field($entry['source'] ?? 'wordpress');
9353 + $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
9354 + $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
9355 +
9356 + if (empty($entry_id)) {
9357 + continue;
9358 + }
9359 +
9360 + if ($source === 'pinecone') {
9361 + if (!$use_pinecone || empty($api_key)) {
9362 + $failed_ids[] = $entry_id;
9363 + $errors[] = "Pinecone not configured for entry: $entry_id";
9364 + continue;
9365 + }
9366 +
9367 + $pinecone_entry_ids[] = $entry_id;
9368 +
9369 + if ($is_group && !empty($source_url)) {
9370 + // Grouped/chunked entry: collect base ID + chunk IDs via List API
9371 + $base_vector_id = md5($source_url);
9372 + $all_vector_ids[] = $base_vector_id;
9373 +
9374 + if ($bulk_docs) {
9375 + $all_vector_ids = array_merge($all_vector_ids, MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $bulk_docs_cfg));
9376 + } else {
9377 + $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
9378 + if (!empty($namespace)) {
9379 + $list_url .= '&namespace=' . rawurlencode($namespace);
9380 + }
9381 + $list_response = wp_remote_get($list_url, array(
9382 + 'headers' => array(
9383 + 'Api-Key' => $api_key,
9384 + 'accept' => 'application/json'
9385 + ),
9386 + 'timeout' => 30
9387 + ));
9388 +
9389 + if (!is_wp_error($list_response)) {
9390 + $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
9391 + if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
9392 + foreach ($list_body['vectors'] as $vector) {
9393 + if (isset($vector['id'])) {
9394 + $all_vector_ids[] = $vector['id'];
9395 + }
9396 + }
9397 + }
9398 + }
9399 + } // end classic (vector index) list
9400 + } else {
9401 + // Single entry: the entry_id IS the vector ID
9402 + $all_vector_ids[] = $entry_id;
9403 + }
9404 +
9405 + if (!empty($source_url)) {
9406 + $vs_mirror_urls[] = $source_url;
9407 + } else {
9408 + $vs_mirror_keys[] = $entry_id;
9409 + }
9410 + } else {
9411 + $wordpress_entries[] = $entry;
9412 + }
9413 + }
9414 +
9415 + // =============================================
9416 + // PHASE 2: Single batch delete to Pinecone
9417 + // =============================================
9418 + if (!empty($all_vector_ids)) {
9419 + $all_vector_ids = array_values(array_unique($all_vector_ids));
9420 + $pinecone_success = true;
9421 + $batches = array_chunk($all_vector_ids, 100);
9422 +
9423 + // Document index (plan 362c31): one documents/delete call for every id.
9424 + if ($bulk_docs) {
9425 + $doc_result = MxChat_Pinecone_Documents::delete_ids($all_vector_ids, $bulk_docs_cfg);
9426 + if (is_wp_error($doc_result)) {
9427 + $pinecone_success = false;
9428 + $errors[] = 'Pinecone batch deletion failed: ' . $doc_result->get_error_message();
9429 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete (document index) failed: ' . $doc_result->get_error_message());
9430 + }
9431 + } else
9432 + foreach ($batches as $batch) {
9433 + $delete_body = array('ids' => $batch);
9434 + if (!empty($namespace)) {
9435 + $delete_body['namespace'] = $namespace;
9436 + }
9437 + $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
9438 + 'headers' => array(
9439 + 'Api-Key' => $api_key,
9440 + 'accept' => 'application/json',
9441 + 'content-type' => 'application/json'
9442 + ),
9443 + 'body' => wp_json_encode($delete_body),
9444 + 'timeout' => 60
9445 + ));
9446 +
9447 + if (is_wp_error($delete_response)) {
9448 + $pinecone_success = false;
9449 + $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
9450 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
9451 + } else {
9452 + $response_code = wp_remote_retrieve_response_code($delete_response);
9453 + if ($response_code !== 200) {
9454 + $pinecone_success = false;
9455 + $response_body = wp_remote_retrieve_body($delete_response);
9456 + $errors[] = "Pinecone API error (HTTP $response_code)";
9457 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
9458 + }
9459 + }
9460 + }
9461 +
9462 + // Mark all pinecone entries based on batch result
9463 + foreach ($pinecone_entry_ids as $eid) {
9464 + if ($pinecone_success) {
9465 + $success_ids[] = $eid;
9466 + } else {
9467 + $failed_ids[] = $eid;
9468 + }
9469 + }
9470 +
9471 + // Mirror the removals to the OpenAI Vector Store (plan 15b5c6)
9472 + if ($pinecone_success && class_exists('MxChat_Vectorstore_Manager')) {
9473 + foreach (array_unique($vs_mirror_urls) as $vs_url) {
9474 + MxChat_Vectorstore_Manager::sync_delete_entry($vs_url, $bot_id);
9475 + }
9476 + foreach (array_unique($vs_mirror_keys) as $vs_key) {
9477 + MxChat_Vectorstore_Manager::sync_delete_by_key($vs_key, $bot_id);
9478 + }
9479 + }
9480 + // Media entries removed here lose their auto-sync signature (b0ae54).
9481 + if ($pinecone_success) {
9482 + foreach (array_unique($vs_mirror_urls) as $vs_url) {
9483 + MxChat_Utils::forget_attachment_signature($vs_url, $bot_id);
9484 + }
9485 + foreach (array_unique($vs_mirror_keys) as $vs_key) {
9486 + MxChat_Utils::forget_attachment_signature_by_key($vs_key, $bot_id);
9487 + }
9488 + }
9489 + }
9490 +
9491 + // =============================================
9492 + // PHASE 3: WordPress database deletions
9493 + // =============================================
9494 + foreach ($wordpress_entries as $entry) {
9495 + $entry_id = sanitize_text_field($entry['id'] ?? '');
9496 + $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
9497 + $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
9498 +
9499 + if (empty($entry_id)) {
9500 + continue;
9501 + }
9502 +
9503 + try {
9504 + if ($is_group && !empty($source_url)) {
9505 + $result = $wpdb->delete(
9506 + $table_name,
9507 + array('source_url' => $source_url),
9508 + array('%s')
9509 + );
9510 + $row_url = $source_url;
9511 + } else {
9512 + // Identity captured pre-delete for the Vector Store mirror
9513 + $row_url = $wpdb->get_var($wpdb->prepare(
9514 + "SELECT source_url FROM {$table_name} WHERE id = %d",
9515 + intval($entry_id)
9516 + ));
9517 + wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
9518 + $result = $wpdb->delete(
9519 + $table_name,
9520 + array('id' => intval($entry_id)),
9521 + array('%d')
9522 + );
9523 + }
9524 +
9525 + if ($result !== false) {
9526 + $success_ids[] = $entry_id;
9527 + // Mirror to the Vector Store: refresh the entry's file when
9528 + // sibling chunk rows survive, remove it when none do.
9529 + if (!empty($row_url)) {
9530 + $remaining = (int) $wpdb->get_var($wpdb->prepare(
9531 + "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
9532 + $row_url
9533 + ));
9534 + if (class_exists('MxChat_Vectorstore_Manager')) {
9535 + if ($remaining > 0) {
9536 + MxChat_Vectorstore_Manager::sync_upsert_entry($row_url, '', $bot_id);
9537 + } else {
9538 + MxChat_Vectorstore_Manager::sync_delete_entry($row_url, $bot_id);
9539 + }
9540 + }
9541 + if ($remaining === 0) {
9542 + MxChat_Utils::forget_attachment_signature($row_url, 'default');
9543 + }
9544 + }
9545 + } else {
9546 + $failed_ids[] = $entry_id;
9547 + $errors[] = "Database error for entry: $entry_id";
9548 + }
9549 + } catch (Exception $e) {
9550 + $failed_ids[] = $entry_id;
9551 + $errors[] = $e->getMessage();
9552 + }
9553 + }
9554 +
9555 + wp_send_json_success(array(
9556 + 'success_ids' => $success_ids,
9557 + 'failed_ids' => $failed_ids,
9558 + 'errors' => $errors,
9559 + 'total_processed' => count($success_ids) + count($failed_ids)
9560 + ));
9561 +
9562 + exit;
9563 +}
9564 +
9565 +/**
9566 + * Get the roles offered for a knowledge-entry restriction.
9567 + *
9568 + * Built from the site's ACTUAL roles (wp_roles()->get_names()) rather than a
9569 + * hardcoded five, so roles added by User Role Editor / Members / a membership
9570 + * plugin can be restricted to. The two virtual levels stay first, and the
9571 + * built-in five keep their existing keys AND their existing labels, so
9572 + * restrictions already stored on entries continue to mean what they meant.
9573 + */
9574 +public function mxchat_get_role_options() {
9575 + return self::mxchat_role_options();
9576 +}
9577 +
9578 +/**
9579 + * The same list, callable without an instance.
9580 + *
9581 + * The constructor registers hooks, so anything outside this class that needs
9582 + * the role list (the post meta box) must not instantiate one just to read it.
9583 + */
9584 +public static function mxchat_role_options() {
9585 + // Existing labels, in the existing order. A built-in that a site has
9586 + // removed is simply not offered.
9587 + $builtin_labels = array(
9588 + 'subscriber' => __('Subscribers & Above', 'mxchat'),
9589 + 'contributor' => __('Contributors & Above', 'mxchat'),
9590 + 'author' => __('Authors & Above', 'mxchat'),
9591 + 'editor' => __('Editors & Above', 'mxchat'),
9592 + 'administrator' => __('Administrators Only', 'mxchat'),
9593 + );
9594 +
9595 + $options = array(
9596 + 'public' => __('Public (Everyone)', 'mxchat'),
9597 + 'logged_in' => __('Logged In Users', 'mxchat'),
9598 + );
9599 +
9600 + $site_roles = function_exists('wp_roles') ? wp_roles()->get_names() : array();
9601 + if (empty($site_roles) || !is_array($site_roles)) {
9602 + // No roles readable (very early boot): fall back to the built-in set so
9603 + // the dropdown is never empty.
9604 + return array_merge($options, $builtin_labels);
9605 + }
9606 +
9607 + foreach ($builtin_labels as $slug => $label) {
9608 + if (isset($site_roles[$slug])) {
9609 + $options[$slug] = $label;
9610 + }
9611 + }
9612 +
9613 + // Everything else the site defines, alphabetical by display name.
9614 + $custom = array();
9615 + foreach ($site_roles as $slug => $name) {
9616 + if (!isset($builtin_labels[$slug])) {
9617 + $custom[$slug] = translate_user_role($name);
9618 + }
9619 + }
9620 + asort($custom);
9621 +
9622 + return array_merge($options, $custom);
9623 +}
9624 +
9625 +/**
9626 + * Check if the current user may see content carrying a role restriction.
9627 + *
9628 + * Access is granted when the user holds the required role outright, or holds
9629 + * a role whose capabilities are a superset of the required role's. That keeps
9630 + * the built-in "& Above" ladder exactly as it was — administrator's caps
9631 + * cover editor's, editor's cover author's, and so on down to subscriber —
9632 + * while working for any custom role, which a hardcoded five-name hierarchy
9633 + * could not.
9634 + *
9635 + * The old hierarchy scored an unknown role 0, so restricting an entry TO a
9636 + * custom role silently granted it to every logged-in user. Fails closed now.
9637 + */
9638 +public function mxchat_user_has_content_access($role_restriction) {
9639 + // Public content is always accessible
9640 + if ($role_restriction === 'public' || empty($role_restriction)) {
9641 + return true;
9642 + }
9643 +
9644 + // Check if user is logged in for logged_in restriction
9645 + if ($role_restriction === 'logged_in') {
9646 + return is_user_logged_in();
9647 + }
9648 +
9649 + // If not logged in, no access to role-restricted content
9650 + if (!is_user_logged_in()) {
9651 + return false;
9652 + }
9653 +
9654 + $user = wp_get_current_user();
9655 + $user_roles = (array) $user->roles;
9656 +
9657 + if (empty($user_roles)) {
9658 + return false;
9659 + }
9660 +
9661 + // Holding the required role is always enough.
9662 + if (in_array($role_restriction, $user_roles, true)) {
9663 + return true;
9664 + }
9665 +
9666 + // Site administrators keep the blanket access the old level-5 ladder gave
9667 + // them. This also stops a restriction naming a since-deleted role from
9668 + // locking the owner out of their own content.
9669 + if (current_user_can('manage_options')) {
9670 + return true;
9671 + }
9672 +
9673 + foreach ($user_roles as $user_role) {
9674 + if (self::mxchat_role_covers($user_role, $role_restriction)) {
9675 + return true;
9676 + }
9677 + }
9678 +
9679 + return false;
9680 +}
9681 +
9682 +/**
9683 + * Does $candidate_role grant everything $required_role grants?
9684 + *
9685 + * Compared on capabilities, not names. Memoised per request because this runs
9686 + * once per retrieved knowledge row.
9687 + *
9688 + * A required role that does not exist, or that grants nothing at all, is NOT
9689 + * treated as covered by anything — an empty capability set is a superset of
9690 + * itself for every role on the site, which would quietly make the restriction
9691 + * meaningless. Those cases fall back to the exact-role check in the caller.
9692 + *
9693 + * @param string $candidate_role Role slug the user holds.
9694 + * @param string $required_role Role slug the entry is restricted to.
9695 + * @return bool
9696 + */
9697 +private static function mxchat_role_covers($candidate_role, $required_role) {
9698 + static $cache = array();
9699 +
9700 + $key = $candidate_role . '|' . $required_role;
9701 + if (isset($cache[$key])) {
9702 + return $cache[$key];
9703 + }
9704 +
9705 + $cache[$key] = false;
9706 +
9707 + if (!function_exists('wp_roles')) {
9708 + return false;
9709 + }
9710 +
9711 + $roles = wp_roles();
9712 + $candidate = $roles->get_role($candidate_role);
9713 + $required = $roles->get_role($required_role);
9714 +
9715 + if (!$candidate || !$required) {
9716 + return false;
9717 + }
9718 +
9719 + $required_caps = self::mxchat_real_caps($required);
9720 + $candidate_caps = self::mxchat_real_caps($candidate);
9721 +
9722 + if (empty($required_caps)) {
9723 + return false;
9724 + }
9725 +
9726 + foreach (array_keys($required_caps) as $cap) {
9727 + if (empty($candidate_caps[$cap])) {
9728 + return false;
9729 + }
9730 + }
9731 +
9732 + $cache[$key] = true;
9733 + return true;
9734 +}
9735 +
9736 +/**
9737 + * A role's granted capabilities, minus the vestigial user-level ones.
9738 + *
9739 + * WordPress still stamps level_0 ... level_10 onto its built-in roles for
9740 + * back-compat with user levels, deprecated since 3.0. They carry no meaning
9741 + * and a hand-built custom role usually has none of them — so comparing them
9742 + * makes a role that genuinely does more than a subscriber fail to cover
9743 + * subscriber, purely because it lacks level_0. Measured on this box: a
9744 + * Teacher role with read/edit_posts/delete_posts/upload_files was denied
9745 + * subscriber-restricted content for exactly that reason.
9746 + *
9747 + * @param WP_Role $role
9748 + * @return array Capability name => true, for granted non-level capabilities.
9749 + */
9750 +/**
9751 + * Of two role restrictions, the one that admits FEWER people.
9752 + *
9753 + * 'public' loses to everything; 'logged_in' loses to any real role. Between
9754 + * two real roles the comparison is the same capability-superset test the
9755 + * access check uses: if A's capabilities cover B's, then everyone who
9756 + * satisfies A also satisfies B, so A is the stricter requirement.
9757 + *
9758 + * Two unrelated custom roles are genuinely incomparable — neither covers the
9759 + * other. In that case $b wins, because callers pass the per-entry choice as
9760 + * $b: an owner who set the dropdown on the page itself meant it.
9761 + *
9762 + * @param string $a Current winner (may be 'public').
9763 + * @param string $b Challenger.
9764 + * @return string
9765 + */
9766 +private static function mxchat_stricter_role($a, $b) {
9767 + $rank = array('public' => 0, 'logged_in' => 1);
9768 +
9769 + if ($b === '' || $b === null) { return $a; }
9770 + if ($a === '' || $a === null) { return $b; }
9771 + if ($a === $b) { return $a; }
9772 +
9773 + $a_virtual = isset($rank[$a]);
9774 + $b_virtual = isset($rank[$b]);
9775 +
9776 + if ($a_virtual && $b_virtual) {
9777 + return $rank[$b] > $rank[$a] ? $b : $a;
9778 + }
9779 + // A real role always outranks 'public' and 'logged_in'.
9780 + if ($a_virtual) { return $b; }
9781 + if ($b_virtual) { return $a; }
9782 +
9783 + // Both are real roles: whichever covers the other is the stricter one.
9784 + if (self::mxchat_role_covers($a, $b)) { return $a; }
9785 + if (self::mxchat_role_covers($b, $a)) { return $b; }
9786 +
9787 + // Incomparable — the explicit per-entry choice wins.
9788 + return $b;
9789 +}
9790 +
9791 +private static function mxchat_real_caps($role) {
9792 + $caps = array();
9793 + foreach ((array) $role->capabilities as $cap => $granted) {
9794 + if (!$granted) {
9795 + continue;
9796 + }
9797 + if (preg_match('/^level_\d+$/', $cap)) {
9798 + continue;
9799 + }
9800 + $caps[$cap] = true;
9801 + }
9802 + return $caps;
9803 +}
9804 +
9805 +/**
9806 + * Handle role restriction updates via AJAX
9807 + * Removed cache clearing call since we removed caching
9808 + */
9809 +public function ajax_mxchat_update_role_restriction() {
9810 + // Verify nonce and permissions
9811 + if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
9812 + wp_send_json_error('Invalid nonce');
9813 + exit;
9814 + }
9815 +
9816 + if (!current_user_can('manage_options')) {
9817 + wp_send_json_error('Unauthorized access');
9818 + exit;
9819 + }
9820 +
9821 + $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
9822 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
9823 + $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
9824 +
9825 + if (empty($entry_id)) {
9826 + wp_send_json_error('Invalid entry ID');
9827 + exit;
9828 + }
9829 +
9830 + // Get knowledge manager instance to validate role restriction
9831 + $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
9832 + $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
9833 + if (!in_array($role_restriction, $valid_roles)) {
9834 + wp_send_json_error('Invalid role restriction');
9835 + exit;
9836 + }
9837 +
9838 + global $wpdb;
9839 +
9840 + if ($data_source === 'pinecone') {
9841 + // Handle Pinecone role restriction (stored separately in WordPress table)
9842 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
9843 +
9844 + // Use REPLACE to insert or update the role restriction
9845 + $result = $wpdb->replace(
9846 + $roles_table,
9847 + array(
9848 + 'vector_id' => $entry_id,
9849 + 'role_restriction' => $role_restriction,
9850 + 'updated_at' => current_time('mysql')
9851 + ),
9852 + array('%s', '%s', '%s')
9853 + );
9854 +
9855 + // No cache clearing needed since we removed caching
9856 +
9857 + } else {
9858 + // Handle WordPress database role restriction (existing functionality)
9859 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9860 +
9861 + $result = $wpdb->update(
9862 + $table_name,
9863 + array('role_restriction' => $role_restriction),
9864 + array('id' => absint($entry_id)),
9865 + array('%s'),
9866 + array('%d')
9867 + );
9868 + }
9869 +
9870 + if ($result === false) {
9871 + wp_send_json_error('Database update failed: ' . $wpdb->last_error);
9872 + exit;
9873 + }
9874 +
9875 + // Keep the OpenAI Vector Store mirror consistent with the new restriction
9876 + // (plan 15b5c6): non-public pulls the mirrored file, public re-mirrors.
9877 + if (class_exists('MxChat_Vectorstore_Manager')) {
9878 + if ($data_source === 'pinecone') {
9879 + if ($role_restriction !== 'public') {
9880 + MxChat_Vectorstore_Manager::sync_delete_by_key($entry_id, 'default');
9881 + }
9882 + // Public again: Pinecone-mode content isn't held locally, so the
9883 + // entry re-mirrors on its next save/import rather than here.
9884 + } else {
9885 + $row_url = $wpdb->get_var($wpdb->prepare(
9886 + "SELECT source_url FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
9887 + absint($entry_id)
9888 + ));
9889 + if (!empty($row_url)) {
9890 + MxChat_Vectorstore_Manager::handle_role_change($row_url, 'default', $role_restriction);
9891 + }
9892 + }
9893 + }
9894 +
9895 + wp_send_json_success(array(
9896 + 'message' => 'Role restriction updated successfully',
9897 + 'role_restriction' => $role_restriction,
9898 + 'data_source' => $data_source,
9899 + 'entry_id' => $entry_id
9900 + ));
9901 + exit;
9902 +}
9903 +
9904 +// ========================================
9905 +// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
9906 +// Add these to your MxChat_Knowledge_Manager class
9907 +// ========================================
9908 +
9909 +/**
9910 + * Initialize role-based content hooks
9911 + * Add this call to your __construct() or mxchat_init_hooks() method
9912 + */
9913 +private function mxchat_init_role_hooks() {
9914 + // AJAX handlers for tag-role mappings
9915 + add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
9916 + add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
9917 + add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
9918 + add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
9919 +
9920 + // Hook to automatically update role restrictions when tags are added/removed
9921 + add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
9922 +
9923 + // Hook to apply role restrictions on auto-sync
9924 + add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
9925 +}
9926 +
9927 +/**
9928 + * Add tag-role mapping via AJAX
9929 + */
9930 +public function ajax_add_tag_role_mapping() {
9931 + // Verify nonce and permissions
9932 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
9933 +
9934 + if (!current_user_can('manage_options')) {
9935 + wp_send_json_error('Unauthorized access');
9936 + exit;
9937 + }
9938 +
9939 + $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
9940 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
9941 +
9942 + if (empty($tag_input)) {
9943 + wp_send_json_error('Please enter a tag name or slug');
9944 + exit;
9945 + }
9946 +
9947 + // Validate role restriction
9948 + $valid_roles = array_keys($this->mxchat_get_role_options());
9949 + if (!in_array($role_restriction, $valid_roles)) {
9950 + wp_send_json_error('Invalid role restriction');
9951 + exit;
9952 + }
9953 +
9954 + // Resolve the tag by slug first, then fall back to its display name, so users can
9955 + // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
9956 + // labeled by name but previously validated by slug only, producing the confusing
9957 + // "Tag does not exist in WordPress" error when a real tag's name was typed.)
9958 + $term = get_term_by('slug', $tag_input, 'post_tag');
9959 + if (!$term) {
9960 + $term = get_term_by('name', $tag_input, 'post_tag');
9961 + }
9962 + if (!$term) {
9963 + 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.');
9964 + exit;
9965 + }
9966 +
9967 + // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
9968 + // compares against each post's tag slugs, so the stored key must be a slug,
9969 + // never the raw (possibly display-name) input.
9970 + $tag_slug = $term->slug;
9971 +
9972 + // Get existing mappings
9973 + $mappings = get_option('mxchat_tag_role_mappings', array());
9974 +
9975 + // Check if mapping already exists
9976 + if (isset($mappings[$tag_slug])) {
9977 + wp_send_json_error('Mapping for this tag already exists');
9978 + exit;
9979 + }
9980 +
9981 + // Add new mapping
9982 + $mappings[$tag_slug] = $role_restriction;
9983 + update_option('mxchat_tag_role_mappings', $mappings);
9984 +
9985 + wp_send_json_success(array(
9986 + 'message' => 'Tag-role mapping added successfully',
9987 + 'tag_slug' => $tag_slug,
9988 + 'role_restriction' => $role_restriction
9989 + ));
9990 + exit;
9991 +}
9992 +
9993 +/**
9994 + * Delete tag-role mapping via AJAX
9995 + */
9996 +public function ajax_delete_tag_role_mapping() {
9997 + // Verify nonce and permissions
9998 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
9999 +
10000 + if (!current_user_can('manage_options')) {
10001 + wp_send_json_error('Unauthorized access');
10002 + exit;
10003 + }
10004 +
10005 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
10006 +
10007 + if (empty($tag_slug)) {
10008 + wp_send_json_error('Tag slug is required');
10009 + exit;
10010 + }
10011 +
10012 + // Get existing mappings
10013 + $mappings = get_option('mxchat_tag_role_mappings', array());
10014 +
10015 + // Check if mapping exists
10016 + if (!isset($mappings[$tag_slug])) {
10017 + wp_send_json_error('Mapping does not exist');
10018 + exit;
10019 + }
10020 +
10021 + // Remove mapping
10022 + unset($mappings[$tag_slug]);
10023 + update_option('mxchat_tag_role_mappings', $mappings);
10024 +
10025 + wp_send_json_success(array(
10026 + 'message' => 'Tag-role mapping deleted successfully',
10027 + 'tag_slug' => $tag_slug
10028 + ));
10029 + exit;
10030 +}
10031 +
10032 +/**
10033 + * Get all tag-role mappings via AJAX
10034 + */
10035 +public function ajax_get_tag_role_mappings() {
10036 + // Verify nonce and permissions
10037 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
10038 +
10039 + if (!current_user_can('manage_options')) {
10040 + wp_send_json_error('Unauthorized access');
10041 + exit;
10042 + }
10043 +
10044 + // Get mappings
10045 + $mappings = get_option('mxchat_tag_role_mappings', array());
10046 + $role_options = $this->mxchat_get_role_options();
10047 +
10048 + $formatted_mappings = array();
10049 +
10050 + foreach ($mappings as $tag_slug => $role_restriction) {
10051 + // Get tag object
10052 + $term = get_term_by('slug', $tag_slug, 'post_tag');
10053 +
10054 + // Count posts with this tag
10055 + $post_count = 0;
10056 + if ($term) {
10057 + $post_count = $term->count;
10058 + }
10059 +
10060 + $formatted_mappings[] = array(
10061 + 'tag_slug' => $tag_slug,
10062 + 'role_restriction' => $role_restriction,
10063 + 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
10064 + 'post_count' => $post_count
10065 + );
10066 + }
10067 +
10068 + wp_send_json_success(array(
10069 + 'mappings' => $formatted_mappings
10070 + ));
10071 + exit;
10072 +}
10073 +
10074 +/**
10075 + * Bulk update role restrictions for all existing content with mapped tags
10076 + */
10077 +public function ajax_bulk_update_tag_roles() {
10078 + // Verify nonce and permissions
10079 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
10080 +
10081 + if (!current_user_can('manage_options')) {
10082 + wp_send_json_error('Unauthorized access');
10083 + exit;
10084 + }
10085 +
10086 + // Get mappings
10087 + $mappings = get_option('mxchat_tag_role_mappings', array());
10088 +
10089 + if (empty($mappings)) {
10090 + wp_send_json_error('No tag-role mappings found');
10091 + exit;
10092 + }
10093 +
10094 + global $wpdb;
10095 +
10096 + // Check if using Pinecone
10097 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10098 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10099 +
10100 + $updated_count = 0;
10101 + $details = array();
10102 +
10103 + foreach ($mappings as $tag_slug => $role_restriction) {
10104 + // Get all posts with this tag
10105 + $posts = get_posts(array(
10106 + 'tag' => $tag_slug,
10107 + 'post_type' => 'any',
10108 + 'posts_per_page' => -1,
10109 + 'fields' => 'ids',
10110 + 'post_status' => 'publish'
10111 + ));
10112 +
10113 + if (empty($posts)) {
10114 + continue;
10115 + }
10116 +
10117 + $tag_updated = 0;
10118 +
10119 + foreach ($posts as $post_id) {
10120 + $source_url = get_permalink($post_id);
10121 + if (!$source_url) {
10122 + continue;
10123 + }
10124 +
10125 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10126 + // Update Pinecone role restriction
10127 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
10128 + $vector_id = md5($source_url);
10129 +
10130 + $result = $wpdb->replace(
10131 + $roles_table,
10132 + array(
10133 + 'vector_id' => $vector_id,
10134 + 'role_restriction' => $role_restriction,
10135 + 'updated_at' => current_time('mysql')
10136 + ),
10137 + array('%s', '%s', '%s')
10138 + );
10139 + } else {
10140 + // Update WordPress DB
10141 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10142 +
10143 + $result = $wpdb->update(
10144 + $table_name,
10145 + array('role_restriction' => $role_restriction),
10146 + array('source_url' => $source_url),
10147 + array('%s'),
10148 + array('%s')
10149 + );
10150 + }
10151 +
10152 + if ($result !== false) {
10153 + $tag_updated++;
10154 + $updated_count++;
10155 + }
10156 + }
10157 +
10158 + if ($tag_updated > 0) {
10159 + $details[] = sprintf(
10160 + 'Tag "%s" (%s): %d posts updated',
10161 + $tag_slug,
10162 + $role_restriction,
10163 + $tag_updated
10164 + );
10165 + }
10166 + }
10167 +
10168 + wp_send_json_success(array(
10169 + 'message' => 'Bulk update completed',
10170 + 'updated_count' => $updated_count,
10171 + 'tags_processed' => count($mappings),
10172 + 'details' => $details
10173 + ));
10174 + exit;
10175 +}
10176 +
10177 +/**
10178 + * Handle tag changes on posts (when tags are added or removed)
10179 + */
10180 +public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
10181 + // Only process post tags
10182 + if ($taxonomy !== 'post_tag') {
10183 + return;
10184 + }
10185 +
10186 + // Get tag-role mappings
10187 + $mappings = get_option('mxchat_tag_role_mappings', array());
10188 +
10189 + if (empty($mappings)) {
10190 + return;
10191 + }
10192 +
10193 + // Get the post's URL
10194 + $source_url = get_permalink($object_id);
10195 + if (!$source_url) {
10196 + return;
10197 + }
10198 +
10199 + // Determine the highest role restriction based on tags
10200 + $highest_role = 'public';
10201 + $role_hierarchy = array(
10202 + 'public' => 0,
10203 + 'logged_in' => 1,
10204 + 'subscriber' => 2,
10205 + 'contributor' => 3,
10206 + 'author' => 4,
10207 + 'editor' => 5,
10208 + 'administrator' => 6
10209 + );
10210 +
10211 + // Get all current tags for the post
10212 + $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
10213 +
10214 + // Find the highest role restriction among the tags
10215 + foreach ($current_tags as $tag_slug) {
10216 + if (isset($mappings[$tag_slug])) {
10217 + $role = $mappings[$tag_slug];
10218 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
10219 + $highest_role = $role;
10220 + }
10221 + }
10222 + }
10223 +
10224 + // Update the role restriction in the database
10225 + global $wpdb;
10226 +
10227 + // Check if using Pinecone
10228 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10229 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10230 +
10231 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10232 + // Update Pinecone role restriction
10233 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
10234 + $vector_id = md5($source_url);
10235 +
10236 + $wpdb->replace(
10237 + $roles_table,
10238 + array(
10239 + 'vector_id' => $vector_id,
10240 + 'role_restriction' => $highest_role,
10241 + 'updated_at' => current_time('mysql')
10242 + ),
10243 + array('%s', '%s', '%s')
10244 + );
10245 + } else {
10246 + // Update WordPress DB
10247 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10248 +
10249 + $wpdb->update(
10250 + $table_name,
10251 + array('role_restriction' => $highest_role),
10252 + array('source_url' => $source_url),
10253 + array('%s'),
10254 + array('%s')
10255 + );
10256 + }
10257 +
10258 + // The entry's restriction just changed — keep the OpenAI Vector Store
10259 + // mirror consistent: non-public pulls the file (file_search has no
10260 + // per-role filtering), public re-mirrors it (plan 15b5c6).
10261 + if (class_exists('MxChat_Vectorstore_Manager')) {
10262 + MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
10263 + }
10264 +}
10265 +
10266 +/**
10267 + * Apply role restriction after content is stored (for auto-sync)
10268 + */
10269 +public function apply_role_restriction_after_storage($post_id, $source_url) {
10270 + // Get tag-role mappings
10271 + $mappings = get_option('mxchat_tag_role_mappings', array());
10272 +
10273 + if (empty($mappings)) {
10274 + return;
10275 + }
10276 +
10277 + // Get all tags for the post
10278 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
10279 +
10280 + if (empty($post_tags)) {
10281 + return;
10282 + }
10283 +
10284 + // Determine the highest role restriction based on tags
10285 + $highest_role = 'public';
10286 + $role_hierarchy = array(
10287 + 'public' => 0,
10288 + 'logged_in' => 1,
10289 + 'subscriber' => 2,
10290 + 'contributor' => 3,
10291 + 'author' => 4,
10292 + 'editor' => 5,
10293 + 'administrator' => 6
10294 + );
10295 +
10296 + foreach ($post_tags as $tag_slug) {
10297 + if (isset($mappings[$tag_slug])) {
10298 + $role = $mappings[$tag_slug];
10299 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
10300 + $highest_role = $role;
10301 + }
10302 + }
10303 + }
10304 +
10305 + // If no restricted tags found, return (leave as public)
10306 + if ($highest_role === 'public') {
10307 + return;
10308 + }
10309 +
10310 + // Update the role restriction
10311 + global $wpdb;
10312 +
10313 + // Check if using Pinecone
10314 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10315 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10316 +
10317 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10318 + // Update Pinecone role restriction
10319 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
10320 + $vector_id = md5($source_url);
10321 +
10322 + $wpdb->replace(
10323 + $roles_table,
10324 + array(
10325 + 'vector_id' => $vector_id,
10326 + 'role_restriction' => $highest_role,
10327 + 'updated_at' => current_time('mysql')
10328 + ),
10329 + array('%s', '%s', '%s')
10330 + );
10331 + } else {
10332 + // Update WordPress DB
10333 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10334 +
10335 + $wpdb->update(
10336 + $table_name,
10337 + array('role_restriction' => $highest_role),
10338 + array('source_url' => $source_url),
10339 + array('%s'),
10340 + array('%s')
10341 + );
10342 + }
10343 +
10344 + // The entry's restriction just changed — keep the OpenAI Vector Store
10345 + // mirror consistent: non-public pulls the file (file_search has no
10346 + // per-role filtering), public re-mirrors it (plan 15b5c6).
10347 + if (class_exists('MxChat_Vectorstore_Manager')) {
10348 + MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
10349 + }
10350 +}
10351 +
10352 +
10353 + // ========================================
10354 + // HELPER METHODS
10355 + // ========================================
10356 +
10357 + /**
10358 + * Check if user has required permissions for content processing
10359 + */
10360 + private function mxchat_check_user_permissions() {
10361 + if (!current_user_can('manage_options')) {
10362 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
10363 + }
10364 + }
10365 +
10366 + /**
10367 + * Validate nonce for security
10368 + */
10369 + private function mxchat_validate_nonce($nonce_name, $nonce_action) {
10370 + if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
10371 + wp_die(esc_html__('Security check failed.', 'mxchat'));
10372 + }
10373 + }
10374 +
10375 + /**
10376 + * Get embedding API credentials
10377 + */
10378 + private function mxchat_get_embedding_credentials() {
10379 + $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
10380 +
10381 + if (strpos($embedding_model, 'text-embedding-') !== false) {
10382 + return array(
10383 + 'type' => 'openai',
10384 + 'api_key' => $this->options['api_key'] ?? ''
10385 + );
10386 + } elseif (strpos($embedding_model, 'voyage-') !== false) {
10387 + return array(
10388 + 'type' => 'voyage',
10389 + 'api_key' => $this->options['voyage_api_key'] ?? ''
10390 + );
10391 + } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
10392 + return array(
10393 + 'type' => 'gemini',
10394 + 'api_key' => $this->options['gemini_api_key'] ?? ''
10395 + );
10396 + }
10397 +
10398 + return array('type' => 'unknown', 'api_key' => '');
10399 + }
10400 +
10401 + /**
10402 + * Log processing errors
10403 + */
10404 + private function mxchat_log_processing_error($operation, $error_message) {
10405 + //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
10406 + }
10407 +
10408 + /**
10409 + * Set admin notice transient
10410 + */
10411 + private function mxchat_set_admin_notice($type, $message) {
10412 + set_transient("mxchat_admin_notice_{$type}", $message, 30);
10413 + }
10414 +
10415 + /**
10416 + * Get Pinecone manager instance for vector operations
10417 + */
10418 + private function mxchat_get_pinecone_manager() {
10419 + return MxChat_Pinecone_Manager::get_instance();
10420 + }
10421 +
10422 +
10423 + // ========================================
10424 +// DATABASE QUEUE TABLE MANAGEMENT
10425 +// ========================================
10426 +
10427 +/**
10428 + * Create queue table on plugin activation
10429 + * Call this from your plugin activation hook
10430 + */
10431 +public function mxchat_create_queue_table() {
10432 + global $wpdb;
10433 +
10434 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10435 + $charset_collate = $wpdb->get_charset_collate();
10436 +
10437 + $sql = "CREATE TABLE IF NOT EXISTS $table_name (
10438 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
10439 + queue_id varchar(64) NOT NULL,
10440 + item_type varchar(20) NOT NULL,
10441 + item_data longtext NOT NULL,
10442 + status varchar(20) NOT NULL DEFAULT 'pending',
10443 + bot_id varchar(50) NOT NULL DEFAULT 'default',
10444 + priority int(11) NOT NULL DEFAULT 0,
10445 + attempts int(11) NOT NULL DEFAULT 0,
10446 + max_attempts int(11) NOT NULL DEFAULT 3,
10447 + error_message text DEFAULT NULL,
10448 + created_at datetime NOT NULL,
10449 + started_at datetime DEFAULT NULL,
10450 + completed_at datetime DEFAULT NULL,
10451 + PRIMARY KEY (id),
10452 + KEY queue_id (queue_id),
10453 + KEY status (status),
10454 + KEY item_type (item_type),
10455 + KEY priority (priority)
10456 + ) $charset_collate;";
10457 +
10458 + require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
10459 + dbDelta($sql);
10460 +
10461 + // Also create a meta table for queue metadata
10462 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10463 +
10464 + $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
10465 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
10466 + queue_id varchar(64) NOT NULL,
10467 + meta_key varchar(255) NOT NULL,
10468 + meta_value longtext,
10469 + PRIMARY KEY (id),
10470 + KEY queue_id (queue_id),
10471 + KEY meta_key (meta_key)
10472 + ) $charset_collate;";
10473 +
10474 + dbDelta($meta_sql);
10475 +}
10476 +
10477 +/**
10478 + * Add items to the processing queue
10479 + *
10480 + * @param string $queue_id Unique identifier for this queue batch
10481 + * @param string $item_type Type of item (url, pdf_page)
10482 + * @param array $items Array of items to queue
10483 + * @param string $bot_id Bot ID for processing
10484 + * @return int Number of items queued
10485 + */
10486 +private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
10487 + global $wpdb;
10488 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10489 +
10490 + $queued_count = 0;
10491 + $priority = 0;
10492 +
10493 + foreach ($items as $item) {
10494 + $result = $wpdb->insert(
10495 + $table_name,
10496 + array(
10497 + 'queue_id' => $queue_id,
10498 + 'item_type' => $item_type,
10499 + 'item_data' => wp_json_encode($item),
10500 + 'status' => 'pending',
10501 + 'bot_id' => $bot_id,
10502 + 'priority' => $priority,
10503 + 'attempts' => 0,
10504 + 'max_attempts' => 3,
10505 + 'created_at' => current_time('mysql')
10506 + ),
10507 + array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
10508 + );
10509 +
10510 + if ($result) {
10511 + $queued_count++;
10512 + }
10513 +
10514 + $priority++; // Process in order
10515 + }
10516 +
10517 + return $queued_count;
10518 +}
10519 +
10520 +/**
10521 + * Store queue metadata (total counts, source URL, etc.)
10522 + */
10523 +private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
10524 + global $wpdb;
10525 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10526 +
10527 + // Check if meta exists
10528 + $existing = $wpdb->get_var($wpdb->prepare(
10529 + "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
10530 + $queue_id,
10531 + $meta_key
10532 + ));
10533 +
10534 + if ($existing) {
10535 + // Update
10536 + $wpdb->update(
10537 + $meta_table,
10538 + array('meta_value' => maybe_serialize($meta_value)),
10539 + array('queue_id' => $queue_id, 'meta_key' => $meta_key),
10540 + array('%s'),
10541 + array('%s', '%s')
10542 + );
10543 + } else {
10544 + // Insert
10545 + $wpdb->insert(
10546 + $meta_table,
10547 + array(
10548 + 'queue_id' => $queue_id,
10549 + 'meta_key' => $meta_key,
10550 + 'meta_value' => maybe_serialize($meta_value)
10551 + ),
10552 + array('%s', '%s', '%s')
10553 + );
10554 + }
10555 +}
10556 +
10557 +/**
10558 + * Get queue metadata
10559 + */
10560 +private function mxchat_get_queue_meta($queue_id, $meta_key) {
10561 + global $wpdb;
10562 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10563 +
10564 + $value = $wpdb->get_var($wpdb->prepare(
10565 + "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
10566 + $queue_id,
10567 + $meta_key
10568 + ));
10569 +
10570 + return maybe_unserialize($value);
10571 +}
10572 +
10573 +// ========================================
10574 +// AJAX QUEUE PROCESSING HANDLERS
10575 +// ========================================
10576 +
10577 +/**
10578 + * AJAX: Get next item from queue to process
10579 + */
10580 +public function ajax_mxchat_get_next_queue_item() {
10581 + // Verify nonce and permissions
10582 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
10583 +
10584 + if (!current_user_can('manage_options')) {
10585 + wp_send_json_error('Unauthorized access');
10586 + }
10587 +
10588 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10589 +
10590 + if (empty($queue_id)) {
10591 + wp_send_json_error('Missing queue ID');
10592 + }
10593 +
10594 + global $wpdb;
10595 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10596 +
10597 + // Get next pending item with retry logic for failed items
10598 + $next_item = $wpdb->get_row($wpdb->prepare(
10599 + "SELECT * FROM $table_name
10600 + WHERE queue_id = %s
10601 + AND status IN ('pending', 'failed')
10602 + AND attempts < max_attempts
10603 + ORDER BY priority ASC, id ASC
10604 + LIMIT 1",
10605 + $queue_id
10606 + ));
10607 +
10608 + if (!$next_item) {
10609 + // No more items - queue complete
10610 + wp_send_json_success(array(
10611 + 'complete' => true,
10612 + 'message' => 'Queue processing complete'
10613 + ));
10614 + }
10615 +
10616 + // Mark item as processing
10617 + $wpdb->update(
10618 + $table_name,
10619 + array(
10620 + 'status' => 'processing',
10621 + 'started_at' => current_time('mysql'),
10622 + 'attempts' => $next_item->attempts + 1
10623 + ),
10624 + array('id' => $next_item->id),
10625 + array('%s', '%s', '%d'),
10626 + array('%d')
10627 + );
10628 +
10629 + wp_send_json_success(array(
10630 + 'complete' => false,
10631 + 'item' => array(
10632 + 'id' => $next_item->id,
10633 + 'type' => $next_item->item_type,
10634 + 'data' => json_decode($next_item->item_data, true),
10635 + 'bot_id' => $next_item->bot_id,
10636 + 'attempt' => $next_item->attempts + 1
10637 + )
10638 + ));
10639 +}
10640 +
10641 +/**
10642 + * AJAX: Process a single queue item
10643 + */
10644 +public function ajax_mxchat_process_queue_item() {
10645 + // Verify nonce and permissions
10646 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
10647 +
10648 + if (!current_user_can('manage_options')) {
10649 + wp_send_json_error('Unauthorized access');
10650 + }
10651 +
10652 + $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
10653 + $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
10654 + $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
10655 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
10656 +
10657 + if (empty($item_id) || empty($item_type)) {
10658 + wp_send_json_error('Missing item data');
10659 + }
10660 +
10661 + global $wpdb;
10662 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10663 +
10664 + // Process based on item type
10665 + try {
10666 + set_time_limit(60); // Give processing 60 seconds
10667 +
10668 + $result = false;
10669 + $error_message = '';
10670 +
10671 + // Read item directly from DB to get queue_id and preserve special chars in item_data
10672 + // (POST round-trip through JS mangles characters like apostrophes in URLs)
10673 + $db_item = $wpdb->get_row($wpdb->prepare(
10674 + "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
10675 + $item_id
10676 + ));
10677 + $item_queue_id = $db_item ? $db_item->queue_id : '';
10678 + if ($db_item && !empty($db_item->item_data)) {
10679 + $db_data = json_decode($db_item->item_data, true);
10680 + if (is_array($db_data)) {
10681 + $item_data = $db_data;
10682 + }
10683 + }
10684 +
10685 + switch ($item_type) {
10686 + case 'url':
10687 + $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
10688 + break;
10689 +
10690 + case 'pdf_page':
10691 + $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
10692 + break;
10693 +
10694 + default:
10695 + throw new Exception('Unknown item type: ' . $item_type);
10696 + }
10697 +
10698 + if (is_wp_error($result)) {
10699 + $error_code = $result->get_error_code();
10700 + // Content errors (empty page, sanitization) are permanent — retrying won't help
10701 + $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
10702 + if (in_array($error_code, $permanent_codes)) {
10703 + // Mark as permanently failed — set attempts = max_attempts so it won't be retried
10704 + $current_item = $wpdb->get_row($wpdb->prepare(
10705 + "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
10706 + ));
10707 + $wpdb->update(
10708 + $table_name,
10709 + array(
10710 + 'status' => 'failed',
10711 + 'error_message' => $result->get_error_message(),
10712 + 'attempts' => $current_item ? $current_item->max_attempts : 3
10713 + ),
10714 + array('id' => $item_id),
10715 + array('%s', '%s', '%d'),
10716 + array('%d')
10717 + );
10718 + wp_send_json_error(array(
10719 + 'message' => $result->get_error_message(),
10720 + 'permanent_failure' => true,
10721 + 'item_id' => $item_id
10722 + ));
10723 + return;
10724 + }
10725 + throw new Exception($result->get_error_message());
10726 + }
10727 +
10728 + if ($result === false) {
10729 + throw new Exception('Processing returned false - item may be empty or invalid');
10730 + }
10731 +
10732 + // Mark as completed
10733 + $wpdb->update(
10734 + $table_name,
10735 + array(
10736 + 'status' => 'completed',
10737 + 'completed_at' => current_time('mysql'),
10738 + 'error_message' => null
10739 + ),
10740 + array('id' => $item_id),
10741 + array('%s', '%s', '%s'),
10742 + array('%d')
10743 + );
10744 +
10745 + wp_send_json_success(array(
10746 + 'processed' => true,
10747 + 'item_id' => $item_id,
10748 + 'message' => 'Item processed successfully'
10749 + ));
10750 +
10751 + } catch (Exception $e) {
10752 + $error_message = $e->getMessage();
10753 +
10754 + // Get current attempt count
10755 + $item = $wpdb->get_row($wpdb->prepare(
10756 + "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
10757 + $item_id
10758 + ));
10759 +
10760 + // Check if we've exhausted retries
10761 + if ($item && $item->attempts >= $item->max_attempts) {
10762 + // Permanently failed
10763 + $wpdb->update(
10764 + $table_name,
10765 + array(
10766 + 'status' => 'failed',
10767 + 'error_message' => $error_message
10768 + ),
10769 + array('id' => $item_id),
10770 + array('%s', '%s'),
10771 + array('%d')
10772 + );
10773 +
10774 + wp_send_json_error(array(
10775 + 'message' => 'Item failed after maximum attempts: ' . $error_message,
10776 + 'permanent_failure' => true,
10777 + 'item_id' => $item_id
10778 + ));
10779 + } else {
10780 + // Mark for retry
10781 + $wpdb->update(
10782 + $table_name,
10783 + array(
10784 + 'status' => 'failed',
10785 + 'error_message' => $error_message
10786 + ),
10787 + array('id' => $item_id),
10788 + array('%s', '%s'),
10789 + array('%d')
10790 + );
10791 +
10792 + wp_send_json_error(array(
10793 + 'message' => 'Item processing failed, will retry: ' . $error_message,
10794 + 'can_retry' => true,
10795 + 'item_id' => $item_id,
10796 + 'attempts' => $item ? $item->attempts : 0
10797 + ));
10798 + }
10799 + }
10800 +}
10801 +
10802 +/**
10803 + * Process a URL from the queue
10804 + */
10805 +private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
10806 + $url = isset($item_data['url']) ? $item_data['url'] : '';
10807 +
10808 + if (empty($url)) {
10809 + return new WP_Error('invalid_url', 'URL is empty');
10810 + }
10811 +
10812 + // Get bot-specific embedding decision early (needed for both paths) —
10813 + // custom-provider-aware (plan cbd5fd). Error code preserved.
10814 + $bot_options = $this->get_bot_options($bot_id);
10815 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
10816 +
10817 + $preflight = MxChat_Utils::embedding_preflight($options);
10818 + if (!$preflight['ok']) {
10819 + return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
10820 + }
10821 + $api_key = $preflight['api_key'];
10822 +
10823 + // Check if this is a WooCommerce product URL and WooCommerce is active
10824 + $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
10825 + $content_type = $is_product_url ? 'product' : 'url';
10826 +
10827 + // Try to get WooCommerce product data if it's a product URL
10828 + if ($is_product_url && class_exists('WooCommerce')) {
10829 + $product_content = $this->mxchat_extract_woocommerce_product_content($url);
10830 +
10831 + if (!empty($product_content)) {
10832 + // Successfully extracted WooCommerce product data with pricing
10833 + $result = MxChat_Utils::submit_content_to_db(
10834 + $product_content,
10835 + $url,
10836 + $api_key,
10837 + null,
10838 + $bot_id,
10839 + 'product'
10840 + );
10841 + return $result;
10842 + }
10843 + // If WooCommerce extraction failed, fall through to HTML extraction
10844 + }
10845 +
10846 + // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
10847 + $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
10848 + $response = wp_remote_get($url, array(
10849 + 'timeout' => $is_likely_pdf ? 120 : 30,
10850 + 'redirection' => 5,
10851 + 'user-agent' => mxchat_ingest_user_agent(),
10852 + ));
10853 +
10854 + if (is_wp_error($response)) {
10855 + return $response;
10856 + }
10857 +
10858 + $response_code = wp_remote_retrieve_response_code($response);
10859 + if ($response_code !== 200) {
10860 + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
10861 + }
10862 +
10863 + // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
10864 + if ($this->mxchat_is_pdf_url($url, $response)) {
10865 + return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
10866 + }
10867 +
10868 + $html = wp_remote_retrieve_body($response);
10869 +
10870 + if (empty($html)) {
10871 + return new WP_Error('empty_response', 'Empty response body');
10872 + }
10873 +
10874 + // Extract and sanitize content
10875 + $content = $this->mxchat_extract_main_content($html);
10876 + $sanitized = $this->mxchat_sanitize_content_for_api($content);
10877 +
10878 + if (empty($sanitized)) {
10879 + // Not an error - just no content found (maybe a redirect or empty page)
10880 + return false;
10881 + }
10882 +
10883 + // Submit to database with content_type (+ the page's own title, plan d9ee66)
10884 + $result = MxChat_Utils::submit_content_to_db(
10885 + $sanitized,
10886 + $url,
10887 + $api_key,
10888 + null,
10889 + $bot_id,
10890 + $content_type,
10891 + MxChat_Utils::extract_page_title($html)
10892 + );
10893 +
10894 + return $result;
10895 +}
10896 +
10897 +/**
10898 + * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
10899 + * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
10900 + * and adds pdf_page items to the same queue so they process with full progress tracking.
10901 + */
10902 +private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
10903 + set_time_limit(120); // PDFs need extra time for download + parsing
10904 +
10905 + $upload_dir = wp_upload_dir();
10906 + $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
10907 + $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
10908 +
10909 + $response_body = wp_remote_retrieve_body($response);
10910 + if (empty($response_body)) {
10911 + return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
10912 + }
10913 +
10914 + if (!wp_mkdir_p(dirname($pdf_path))) {
10915 + return new WP_Error('dir_error', 'Failed to create upload directory');
10916 + }
10917 +
10918 + file_put_contents($pdf_path, $response_body);
10919 +
10920 + if (!file_exists($pdf_path)) {
10921 + return new WP_Error('save_error', 'Failed to save PDF file');
10922 + }
10923 +
10924 + try {
10925 + $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
10926 +
10927 + if ($total_pages === false || $total_pages < 1) {
10928 + wp_delete_file($pdf_path);
10929 + return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
10930 + }
10931 +
10932 + // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
10933 + $pages = array();
10934 + for ($i = 1; $i <= $total_pages; $i++) {
10935 + $pages[] = array(
10936 + 'pdf_path' => $pdf_path,
10937 + 'pdf_url' => $pdf_url,
10938 + 'page_number' => $i,
10939 + 'total_pages' => $total_pages
10940 + );
10941 + }
10942 +
10943 + // Add pdf_page items to the SAME queue so the JS picks them up automatically
10944 + if (!empty($queue_id)) {
10945 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
10946 + } else {
10947 + // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
10948 + $new_queue_id = 'pdf_' . md5($pdf_url . time());
10949 + $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
10950 + $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
10951 + $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
10952 + $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
10953 + $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
10954 + $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
10955 + $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
10956 + }
10957 +
10958 + if ($queued_count === 0) {
10959 + wp_delete_file($pdf_path);
10960 + return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
10961 + }
10962 +
10963 + // Return true so the original URL item is marked complete
10964 + // The new pdf_page items will be processed in subsequent batches
10965 + return true;
10966 +
10967 + } catch (Exception $e) {
10968 + if (file_exists($pdf_path)) {
10969 + wp_delete_file($pdf_path);
10970 + }
10971 + return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
10972 + }
10973 +}
10974 +
10975 +/**
10976 + * Legacy: Process a PDF URL inline during sitemap queue processing.
10977 + * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
10978 + */
10979 +private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
10980 + set_time_limit(120); // PDFs need more time — downloading + parsing all pages
10981 +
10982 + $upload_dir = wp_upload_dir();
10983 + $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
10984 + $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
10985 +
10986 + $response_body = wp_remote_retrieve_body($response);
10987 + if (empty($response_body)) {
10988 + return new WP_Error('empty_pdf', 'Empty PDF response');
10989 + }
10990 +
10991 + if (!wp_mkdir_p(dirname($pdf_path))) {
10992 + return new WP_Error('dir_error', 'Failed to create upload directory');
10993 + }
10994 +
10995 + file_put_contents($pdf_path, $response_body);
10996 +
10997 + if (!file_exists($pdf_path)) {
10998 + return new WP_Error('save_error', 'Failed to save PDF file');
10999 + }
11000 +
11001 + try {
11002 + mxchat_load_pdf_parser();
11003 + $parser = new \Smalot\PdfParser\Parser();
11004 + $pdf = $parser->parseFile($pdf_path);
11005 + $pages = $pdf->getPages();
11006 + $total_pages = count($pages);
11007 +
11008 + if ($total_pages < 1) {
11009 + wp_delete_file($pdf_path);
11010 + return new WP_Error('no_pages', 'PDF has no pages');
11011 + }
11012 +
11013 + $processed = 0;
11014 + $skipped_pages = array();
11015 +
11016 + for ($i = 0; $i < $total_pages; $i++) {
11017 + $page_num = $i + 1;
11018 + $text = $pages[$i]->getText();
11019 + $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_import page ' . $page_num);
11020 + if (empty($text)) {
11021 + $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
11022 + continue;
11023 + }
11024 +
11025 + $sanitized = $this->mxchat_sanitize_content_for_api($text);
11026 + if (empty($sanitized)) {
11027 + $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
11028 + continue;
11029 + }
11030 +
11031 + $metadata = array(
11032 + 'document_type' => 'pdf',
11033 + 'total_pages' => $total_pages,
11034 + 'current_page' => $page_num,
11035 + 'source_url' => $pdf_url,
11036 + );
11037 +
11038 + $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
11039 + $page_url = MxChat_Utils::pdf_page_identity($pdf_url, $page_num);
11040 +
11041 + MxChat_Utils::submit_content_to_db(
11042 + $content_with_metadata,
11043 + $page_url,
11044 + $api_key,
11045 + null,
11046 + $bot_id,
11047 + 'pdf'
11048 + );
11049 +
11050 + $processed++;
11051 + }
11052 +
11053 + // Clean up the temp PDF file
11054 + wp_delete_file($pdf_path);
11055 +
11056 + if (!empty($skipped_pages)) {
11057 + error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
11058 + }
11059 +
11060 + return $processed > 0 ? true : false;
11061 +
11062 + } catch (Exception $e) {
11063 + if (file_exists($pdf_path)) {
11064 + wp_delete_file($pdf_path);
11065 + }
11066 + return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
11067 + }
11068 +}
11069 +
11070 +/**
11071 + * Extract WooCommerce product content including pricing
11072 + *
11073 + * @param string $url The product URL
11074 + * @return string|false Product content with pricing, or false if not found
11075 + */
11076 +private function mxchat_extract_woocommerce_product_content($url) {
11077 + // Try to get product ID from URL
11078 + $product_id = url_to_postid($url);
11079 +
11080 + // If url_to_postid fails, try to extract from URL pattern
11081 + if (!$product_id) {
11082 + $product_slug = '';
11083 +
11084 + // Handle pretty permalinks: /product/product-name/
11085 + if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
11086 + $product_slug = $matches[1];
11087 + }
11088 +
11089 + if (!empty($product_slug)) {
11090 + $product_post = get_page_by_path($product_slug, OBJECT, 'product');
11091 + if ($product_post) {
11092 + $product_id = $product_post->ID;
11093 + }
11094 + }
11095 + }
11096 +
11097 + if (!$product_id) {
11098 + return false;
11099 + }
11100 +
11101 + // Get WooCommerce product object
11102 + $product = wc_get_product($product_id);
11103 +
11104 + if (!$product) {
11105 + return false;
11106 + }
11107 +
11108 + // Build product content via the shared WC-object assembler (a3d60c) — same
11109 + // body as the auto-sync product writer, so the two paths can never drift.
11110 + $content = $this->mxchat_prepare_product_content_for_indexing($product);
11111 +
11112 + return $this->mxchat_sanitize_content_for_api($content);
11113 +}
11114 +
11115 +/**
11116 + * Process a PDF page from the queue
11117 + */
11118 +private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
11119 + $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
11120 + $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
11121 + $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
11122 + $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
11123 +
11124 + if (empty($pdf_path) || !file_exists($pdf_path)) {
11125 + return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
11126 + }
11127 +
11128 + if ($page_number < 1) {
11129 + return new WP_Error('invalid_page', 'Invalid page number');
11130 + }
11131 +
11132 + try {
11133 + mxchat_load_pdf_parser();
11134 + $parser = new \Smalot\PdfParser\Parser();
11135 + $pdf = $parser->parseFile($pdf_path);
11136 + $pages = $pdf->getPages();
11137 +
11138 + if (!isset($pages[$page_number - 1])) {
11139 + return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
11140 + }
11141 +
11142 + $text = $pages[$page_number - 1]->getText();
11143 + $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_page page ' . $page_number);
11144 +
11145 + if (empty($text)) {
11146 + return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
11147 + }
11148 +
11149 + $sanitized = $this->mxchat_sanitize_content_for_api($text);
11150 +
11151 + if (empty($sanitized)) {
11152 + 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');
11153 + }
11154 +
11155 + // Create metadata
11156 + $metadata = array(
11157 + 'document_type' => 'pdf',
11158 + 'total_pages' => $total_pages,
11159 + 'current_page' => $page_number,
11160 + 'source_url' => $pdf_url
11161 + );
11162 +
11163 + $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
11164 + $page_url = MxChat_Utils::pdf_page_identity($pdf_url, $page_number);
11165 +
11166 + // Get bot-specific embedding decision — custom-provider-aware
11167 + // (plan cbd5fd). Error code preserved.
11168 + $bot_options = $this->get_bot_options($bot_id);
11169 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
11170 +
11171 + $preflight = MxChat_Utils::embedding_preflight($options);
11172 + if (!$preflight['ok']) {
11173 + return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
11174 + }
11175 + $api_key = $preflight['api_key'];
11176 +
11177 + // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
11178 + $result = MxChat_Utils::submit_content_to_db(
11179 + $content_with_metadata,
11180 + $page_url,
11181 + $api_key,
11182 + null,
11183 + $bot_id,
11184 + 'pdf'
11185 + );
11186 +
11187 + return $result;
11188 +
11189 + } catch (Exception $e) {
11190 + return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
11191 + }
11192 +}
11193 +
11194 +/**
11195 + * AJAX: Get queue processing status
11196 + */
11197 +public function ajax_mxchat_get_queue_status() {
11198 + // Verify nonce and permissions
11199 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
11200 +
11201 + if (!current_user_can('manage_options')) {
11202 + wp_send_json_error('Unauthorized access');
11203 + }
11204 +
11205 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11206 +
11207 + if (empty($queue_id)) {
11208 + wp_send_json_error('Missing queue ID');
11209 + }
11210 +
11211 + global $wpdb;
11212 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
11213 +
11214 + // Get counts by status
11215 + $counts = $wpdb->get_results($wpdb->prepare(
11216 + "SELECT status, COUNT(*) as count
11217 + FROM $table_name
11218 + WHERE queue_id = %s
11219 + GROUP BY status",
11220 + $queue_id
11221 + ), OBJECT_K);
11222 +
11223 + $total = 0;
11224 + $completed = 0;
11225 + $failed = 0;
11226 + $processing = 0;
11227 + $pending = 0;
11228 +
11229 + foreach ($counts as $status => $data) {
11230 + $count = absint($data->count);
11231 + $total += $count;
11232 +
11233 + switch ($status) {
11234 + case 'completed':
11235 + $completed = $count;
11236 + break;
11237 + case 'failed':
11238 + $failed = $count;
11239 + break;
11240 + case 'processing':
11241 + $processing = $count;
11242 + break;
11243 + case 'pending':
11244 + $pending = $count;
11245 + break;
11246 + }
11247 + }
11248 +
11249 + // Calculate percentage
11250 + $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
11251 +
11252 + // Get failed items details (include all failed items, not just those that exhausted retries)
11253 + $failed_items = array();
11254 + if ($failed > 0) {
11255 + $failed_items = $wpdb->get_results($wpdb->prepare(
11256 + "SELECT item_type, item_data, error_message, attempts
11257 + FROM $table_name
11258 + WHERE queue_id = %s
11259 + AND status = 'failed'
11260 + ORDER BY id DESC
11261 + LIMIT 50",
11262 + $queue_id
11263 + ));
11264 + }
11265 +
11266 + // Get queue metadata
11267 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
11268 + $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
11269 +
11270 + // Determine if queue is complete
11271 + $is_complete = ($pending === 0 && $processing === 0);
11272 +
11273 + wp_send_json_success(array(
11274 + 'queue_id' => $queue_id,
11275 + 'queue_type' => $queue_type,
11276 + 'source_url' => $source_url,
11277 + 'total' => $total,
11278 + 'completed' => $completed,
11279 + 'failed' => $failed,
11280 + 'processing' => $processing,
11281 + 'pending' => $pending,
11282 + 'percentage' => $percentage,
11283 + 'is_complete' => $is_complete,
11284 + 'failed_items' => $failed_items,
11285 + 'status' => $is_complete ? 'complete' : 'processing'
11286 + ));
11287 +}
11288 +
11289 +/**
11290 + * AJAX: Clear completed queue
11291 + */
11292 +public function ajax_mxchat_clear_queue() {
11293 + // Verify nonce and permissions
11294 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
11295 +
11296 + if (!current_user_can('manage_options')) {
11297 + wp_send_json_error('Unauthorized access');
11298 + }
11299 +
11300 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11301 +
11302 + if (empty($queue_id)) {
11303 + wp_send_json_error('Missing queue ID');
11304 + }
11305 +
11306 + global $wpdb;
11307 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
11308 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
11309 +
11310 + // Delete queue items
11311 + $wpdb->delete(
11312 + $table_name,
11313 + array('queue_id' => $queue_id),
11314 + array('%s')
11315 + );
11316 +
11317 + // Delete queue metadata
11318 + $wpdb->delete(
11319 + $meta_table,
11320 + array('queue_id' => $queue_id),
11321 + array('%s')
11322 + );
11323 +
11324 + wp_send_json_success(array(
11325 + 'message' => 'Queue cleared successfully'
11326 + ));
11327 +}
11328 +
11329 +/**
11330 + * AJAX: Retry failed items in queue
11331 + */
11332 +public function ajax_mxchat_retry_failed() {
11333 + // Verify nonce and permissions
11334 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
11335 +
11336 + if (!current_user_can('manage_options')) {
11337 + wp_send_json_error('Unauthorized access');
11338 + }
11339 +
11340 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11341 +
11342 + if (empty($queue_id)) {
11343 + wp_send_json_error('Missing queue ID');
11344 + }
11345 +
11346 + global $wpdb;
11347 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
11348 +
11349 + // Reset failed items to pending and reset attempt count
11350 + $updated = $wpdb->update(
11351 + $table_name,
11352 + array(
11353 + 'status' => 'pending',
11354 + 'attempts' => 0,
11355 + 'error_message' => null
11356 + ),
11357 + array(
11358 + 'queue_id' => $queue_id,
11359 + 'status' => 'failed'
11360 + ),
11361 + array('%s', '%d', '%s'),
11362 + array('%s', '%s')
11363 + );
11364 +
11365 + wp_send_json_success(array(
11366 + 'message' => 'Reset ' . $updated . ' failed items for retry',
11367 + 'reset_count' => $updated
11368 + ));
11369 +}
11370 +
11371 +
11372 +public function ajax_mxchat_mark_queue_complete() {
11373 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
11374 +
11375 + if (!current_user_can('manage_options')) {
11376 + wp_send_json_error('Unauthorized access');
11377 + }
11378 +
11379 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11380 +
11381 + if (empty($queue_id)) {
11382 + wp_send_json_error('Missing queue ID');
11383 + }
11384 +
11385 + // Clear active queue transients
11386 + if (strpos($queue_id, 'sitemap_') === 0) {
11387 + delete_transient('mxchat_active_queue_sitemap');
11388 + } else if (strpos($queue_id, 'pdf_') === 0) {
11389 + delete_transient('mxchat_active_queue_pdf');
11390 + }
11391 +
11392 + wp_send_json_success(array('message' => 'Queue marked as complete'));
11393 +}
11394 +
11395 +
11396 + // ========================================
11397 + // STATIC ACCESS METHODS
11398 + // ========================================
11399 +
11400 + /**
11401 + * Get singleton instance
11402 + */
11403 + public static function get_instance() {
11404 + static $instance = null;
11405 + if ($instance === null) {
11406 + $instance = new self();
11407 + }
11408 + return $instance;
11409 + }
11410 +}
11411 +
11412 +// Initialize the Knowledge manager
7894 11413 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();