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

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

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