PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.0.9
MxChat – AI Chatbot & Content Generation for WordPress v3.0.9
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | admin/class-knowledge-manager.php +7116 -8655 3.2.153.0.9 View file →
@@ -1,8656 +1,7117 @@
1 -<?php
2 -/**
3 - * File: admin/class-knowledge-manager.php
4 - *
5 - * Handles all knowledge base content processing for MxChat
6 - * Including PDF, sitemap, content processing, and WordPress post management
7 - */
8 -if (!defined('ABSPATH')) {
9 - exit; // Exit if accessed directly
10 -}
11 -
12 -class MxChat_Knowledge_Manager {
13 -
14 - private $options;
15 -
16 - /**
17 - * Constructor - Register hooks for content processing
18 - */
19 -public function __construct() {
20 - $this->options = get_option('mxchat_options', array());
21 - $this->mxchat_init_hooks();
22 -
23 - $this->mxchat_init_role_hooks();
24 -}
25 -
26 -/**
27 - * Initialize WordPress hooks for content processing
28 - *
29 - */
30 -private function mxchat_init_hooks() {
31 - // Admin post handlers for form submissions
32 - add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 - add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 - add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
35 - add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission'));
36 - add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
37 -
38 - // AJAX handlers for real-time processing and status updates
39 - add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
40 - add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
41 - add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
42 - add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
43 - add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
44 - add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
45 - add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
46 - add_action('wp_ajax_mxchat_delete_chunks_by_url', array($this, 'ajax_mxchat_delete_chunks_by_url'));
47 - add_action('wp_ajax_mxchat_delete_wordpress_prompt', array($this, 'ajax_mxchat_delete_wordpress_prompt'));
48 - add_action('wp_ajax_mxchat_bulk_delete_knowledge', array($this, 'ajax_mxchat_bulk_delete_knowledge'));
49 - add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
50 -
51 - // Queue-based processing AJAX handlers
52 - add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
53 - add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
54 - add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
55 - add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
56 - add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
57 - add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
58 - add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
59 - add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
60 - add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
61 - add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
62 - add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
63 - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
64 -
65 - // WordPress post management hooks
66 - add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
67 - add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
68 - add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
69 - add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
70 -
71 - // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
72 - // Priority 20 to run after ACF's own save (which runs at priority 10)
73 - add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
74 -
75 - add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
76 -
77 - // WooCommerce product hooks (if WooCommerce is active)
78 - if (class_exists('WooCommerce')) {
79 - add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
80 - add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
81 - add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
82 - add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
83 - }
84 -}
85 -
86 - /**
87 - * Get current options (refreshed)
88 - */
89 - private function mxchat_get_options() {
90 - if (empty($this->options)) {
91 - $this->options = get_option('mxchat_options', array());
92 - }
93 - return $this->options;
94 - }
95 -
96 -
97 - // ========================================
98 - // MAIN CONTENT SUBMISSION HANDLERS
99 - // ========================================
100 -
101 -public function mxchat_handle_content_submission() {
102 - // Check if the form was submitted and the user has permission.
103 - if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
104 - return;
105 - }
106 -
107 - // Verify the nonce.
108 - $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
109 - if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
110 - wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
111 - }
112 -
113 - // Sanitize the inputs.
114 - // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
115 - $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
116 - $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
117 -
118 - // Get bot_id from form submission
119 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
120 -
121 - // Get bot-specific options and API key
122 - $bot_options = $this->get_bot_options($bot_id);
123 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
124 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
125 -
126 - if (strpos($selected_model, 'voyage') === 0) {
127 - $api_key = $options['voyage_api_key'] ?? '';
128 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
129 - $api_key = $options['gemini_api_key'] ?? '';
130 - } else {
131 - $api_key = $options['api_key'] ?? '';
132 - }
133 -
134 - if (empty($api_key)) {
135 - set_transient('mxchat_admin_notice_error',
136 - esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
137 - 30
138 - );
139 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
140 - exit;
141 - }
142 -
143 - // Use centralized utility function with bot_id
144 - $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
145 -
146 - if (is_wp_error($result)) {
147 - set_transient('mxchat_admin_notice_error',
148 - esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
149 - 30
150 - );
151 - } else {
152 - set_transient('mxchat_admin_notice_success',
153 - esc_html__('Content successfully submitted!', 'mxchat'),
154 - 30
155 - );
156 - }
157 -
158 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
159 - exit;
160 -}
161 -
162 -/**
163 - * Handle the "YouTube" KB import source (admin-post form submission).
164 - *
165 - * Per-video description mode:
166 - * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
167 - * If no usable transcript, index the metadata anyway, tell the admin,
168 - * and bounce back with the manual box pre-filled (never fail silently).
169 - * - manual: the admin's own description is what gets indexed; metadata rides along.
170 - *
171 - * The row is stored with content_type 'youtube' and source_url = the canonical
172 - * watch URL, so re-importing the same video UPDATES the entry (source_url
173 - * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
174 - * "augment a metadata-only entry" path.
175 - */
176 -public function mxchat_handle_youtube_submission() {
177 - if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
178 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
179 - }
180 -
181 - check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
182 -
183 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
184 -
185 - $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
186 - $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
187 -
188 - if (empty($video_id)) {
189 - set_transient('mxchat_admin_notice_error',
190 - esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
191 - 30
192 - );
193 - wp_safe_redirect(esc_url($redirect_url));
194 - exit;
195 - }
196 -
197 - $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
198 -
199 - $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
200 - $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
201 -
202 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
203 -
204 - // Resolve the embedding API key exactly like the sibling handlers.
205 - $bot_options = $this->get_bot_options($bot_id);
206 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
207 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
208 -
209 - if (strpos($selected_model, 'voyage') === 0) {
210 - $api_key = $options['voyage_api_key'] ?? '';
211 - $provider_name = 'Voyage AI';
212 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
213 - $api_key = $options['gemini_api_key'] ?? '';
214 - $provider_name = 'Google Gemini';
215 - } else {
216 - $api_key = $options['api_key'] ?? '';
217 - $provider_name = 'OpenAI';
218 - }
219 -
220 - if (empty($api_key)) {
221 - set_transient('mxchat_admin_notice_error',
222 - sprintf(
223 - esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
224 - $provider_name
225 - ),
226 - 30
227 - );
228 - wp_safe_redirect(esc_url($redirect_url));
229 - exit;
230 - }
231 -
232 - // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
233 - // manual mode it enriches the indexed text with the real title/channel.
234 - $meta = $this->mxchat_fetch_youtube_oembed($video_id);
235 - $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
236 - $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
237 -
238 - $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
239 - if ($video_channel !== '') {
240 - $header_lines .= 'Channel: ' . $video_channel . "\n";
241 - }
242 - $header_lines .= 'URL: ' . $canonical_url . "\n\n";
243 -
244 - $transcript_missing = false;
245 -
246 - if ($description_mode === 'manual') {
247 - if ($manual_description === '') {
248 - set_transient('mxchat_admin_notice_error',
249 - esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
250 - 30
251 - );
252 - wp_safe_redirect(esc_url($redirect_url));
253 - exit;
254 - }
255 - $indexed_text = $header_lines . $manual_description;
256 - } else {
257 - $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
258 -
259 - if (strlen($transcript) >= 200) {
260 - $indexed_text = $header_lines . $transcript;
261 - } else {
262 - // Graceful fallback: captions disabled / blocked / no speech. Auto
263 - // reliably gets metadata; it does NOT guarantee a transcript.
264 - $transcript_missing = true;
265 -
266 - if ($video_title === '' && $video_channel === '') {
267 - // Both halves failed — nothing meaningful to index.
268 - set_transient('mxchat_admin_notice_error',
269 - 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'),
270 - 30
271 - );
272 - wp_safe_redirect(esc_url($redirect_url));
273 - exit;
274 - }
275 -
276 - $indexed_text = $header_lines . sprintf(
277 - /* translators: 1: video title, 2: channel name */
278 - __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
279 - $video_title !== '' ? $video_title : $canonical_url,
280 - $video_channel !== '' ? $video_channel : 'YouTube'
281 - );
282 - }
283 - }
284 -
285 - $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
286 -
287 - if (is_wp_error($result)) {
288 - set_transient('mxchat_admin_notice_error',
289 - esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
290 - 30
291 - );
292 - wp_safe_redirect(esc_url($redirect_url));
293 - exit;
294 - }
295 -
296 - if ($transcript_missing) {
297 - set_transient('mxchat_admin_notice_success',
298 - 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'),
299 - 30
300 - );
301 - // Bounce back with prefill args so the page reopens the YouTube form in
302 - // manual mode with the URL + fetched title ready to augment.
303 - $redirect_url = add_query_arg(array(
304 - 'mxchat_yt_prefill' => '1',
305 - 'yt_url' => rawurlencode($canonical_url),
306 - 'yt_title' => rawurlencode($video_title),
307 - ), $redirect_url);
308 - } else {
309 - set_transient('mxchat_admin_notice_success',
310 - esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
311 - 30
312 - );
313 - }
314 -
315 - wp_safe_redirect(esc_url_raw($redirect_url));
316 - exit;
317 -}
318 -
319 -/**
320 - * Fetch YouTube oEmbed metadata for a video (no API key required).
321 - * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
322 - */
323 -private function mxchat_fetch_youtube_oembed($video_id) {
324 - $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
325 - $response = wp_remote_get($oembed_url, array('timeout' => 15));
326 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
327 - return array();
328 - }
329 - $data = json_decode(wp_remote_retrieve_body($response), true);
330 - return is_array($data) ? $data : array();
331 -}
332 -
333 -/**
334 - * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
335 - * YouTube's unofficial timedtext route (the caption track list embedded in the
336 - * watch page), which YouTube has broken before and will break again. Every
337 - * failure mode returns '' so a break degrades to the metadata-only import path
338 - * instead of erroring the whole submission. Do not let anything in here throw.
339 - */
340 -private function mxchat_fetch_youtube_transcript($video_id) {
341 - $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
342 -
343 - // First try the honest ingest UA; some responses omit the player config for
344 - // bot UAs, so retry once with a browser UA before giving up.
345 - $user_agents = array(
346 - mxchat_ingest_user_agent(),
347 - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
348 - );
349 -
350 - $tracks = array();
351 - foreach ($user_agents as $ua) {
352 - $response = wp_remote_get($watch_url, array(
353 - 'timeout' => 20,
354 - 'user-agent' => $ua,
355 - ));
356 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
357 - continue;
358 - }
359 - $body = wp_remote_retrieve_body($response);
360 - if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
361 - continue;
362 - }
363 - $decoded = json_decode($m[1], true);
364 - if (is_array($decoded) && !empty($decoded)) {
365 - $tracks = $decoded;
366 - break;
367 - }
368 - }
369 -
370 - if (empty($tracks)) {
371 - return '';
372 - }
373 -
374 - // Prefer an English track, else take the first offered.
375 - $chosen = null;
376 - foreach ($tracks as $track) {
377 - if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
378 - $chosen = $track;
379 - break;
380 - }
381 - }
382 - if ($chosen === null) {
383 - $chosen = $tracks[0];
384 - }
385 - if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
386 - return '';
387 - }
388 -
389 - $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
390 - if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
391 - return '';
392 - }
393 - $xml = wp_remote_retrieve_body($timedtext);
394 - if (!is_string($xml) || strpos($xml, '<text') === false) {
395 - return '';
396 - }
397 -
398 - // <text start=".." dur="..">caption</text> — strip tags, decode the
399 - // double-encoded entities timedtext ships, collapse whitespace.
400 - $text = preg_replace('/<[^>]+>/', ' ', $xml);
401 - $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
402 - $text = trim(preg_replace('/\s+/u', ' ', $text));
403 -
404 - return $text;
405 -}
406 -
407 -public function mxchat_is_pdf_url($url, $response) {
408 - $content_type = wp_remote_retrieve_header($response, 'content-type');
409 - $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
410 -
411 - // Check Content-Disposition header for .pdf filename (Google Drive sends this)
412 - $disposition = wp_remote_retrieve_header($response, 'content-disposition');
413 - $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
414 -
415 - return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
416 -}
417 -
418 -
419 -public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
420 - if (!current_user_can('manage_options')) {
421 - return false;
422 - }
423 -
424 - $pdf_url = esc_url_raw($pdf_url);
425 - $upload_dir = wp_upload_dir();
426 -
427 - if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
428 - return false;
429 - }
430 -
431 - $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
432 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
433 -
434 - $response_body = wp_remote_retrieve_body($response);
435 - if (empty($response_body)) {
436 - return false;
437 - }
438 -
439 - if (!wp_mkdir_p(dirname($pdf_path))) {
440 - return false;
441 - }
442 -
443 - try {
444 - file_put_contents($pdf_path, $response_body);
445 -
446 - if (!file_exists($pdf_path)) {
447 - throw new Exception(__('Failed to save PDF file', 'mxchat'));
448 - }
449 -
450 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
451 -
452 - if ($total_pages === false || $total_pages < 1) {
453 - throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
454 - }
455 -
456 - // Create unique queue ID
457 - $queue_id = 'pdf_' . md5($pdf_url . time());
458 -
459 - // Create array of pages to process
460 - $pages = array();
461 - for ($i = 1; $i <= $total_pages; $i++) {
462 - $pages[] = array(
463 - 'pdf_path' => $pdf_path,
464 - 'pdf_url' => $pdf_url,
465 - 'page_number' => $i,
466 - 'total_pages' => $total_pages
467 - );
468 - }
469 -
470 - // Add pages to queue
471 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
472 -
473 - if ($queued_count === 0) {
474 - wp_delete_file($pdf_path);
475 - throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
476 - }
477 -
478 - // Store queue metadata
479 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
480 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
481 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
482 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
483 - $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
484 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
485 -
486 - // Store queue ID in transient for status tracking
487 - set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
488 - set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
489 -
490 - return 'queued';
491 -
492 - } catch (Exception $e) {
493 - if (file_exists($pdf_path)) {
494 - wp_delete_file($pdf_path);
495 - }
496 - return $e->getMessage();
497 - }
498 -}
499 -
500 -/**
501 - * Handle direct PDF file upload from the knowledge base page
502 - */
503 -public function mxchat_handle_pdf_file_submission() {
504 - if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
505 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
506 - }
507 -
508 - check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
509 -
510 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
511 -
512 - // Validate file upload
513 - if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
514 - $error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
515 - $error_messages = array(
516 - UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
517 - UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
518 - UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
519 - UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
520 - UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
521 - UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
522 - );
523 - $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
524 - set_transient('mxchat_admin_notice_error', $error_msg, 30);
525 - wp_safe_redirect(esc_url($redirect_url));
526 - exit;
527 - }
528 -
529 - $file = $_FILES['pdf_file'];
530 -
531 - // Validate MIME type
532 - $finfo = finfo_open(FILEINFO_MIME_TYPE);
533 - $mime_type = finfo_file($finfo, $file['tmp_name']);
534 - finfo_close($finfo);
535 -
536 - if ($mime_type !== 'application/pdf') {
537 - set_transient('mxchat_admin_notice_error',
538 - esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
539 - 30
540 - );
541 - wp_safe_redirect(esc_url($redirect_url));
542 - exit;
543 - }
544 -
545 - // Validate extension
546 - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
547 - if ($ext !== 'pdf') {
548 - set_transient('mxchat_admin_notice_error',
549 - esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
550 - 30
551 - );
552 - wp_safe_redirect(esc_url($redirect_url));
553 - exit;
554 - }
555 -
556 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
557 - $original_filename = sanitize_file_name($file['name']);
558 -
559 - $upload_dir = wp_upload_dir();
560 - if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
561 - set_transient('mxchat_admin_notice_error',
562 - esc_html__('WordPress upload directory is not writable.', 'mxchat'),
563 - 30
564 - );
565 - wp_safe_redirect(esc_url($redirect_url));
566 - exit;
567 - }
568 -
569 - $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
570 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
571 -
572 - if (!wp_mkdir_p(dirname($pdf_path))) {
573 - set_transient('mxchat_admin_notice_error',
574 - esc_html__('Failed to create upload directory.', 'mxchat'),
575 - 30
576 - );
577 - wp_safe_redirect(esc_url($redirect_url));
578 - exit;
579 - }
580 -
581 - // Move uploaded file
582 - if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
583 - set_transient('mxchat_admin_notice_error',
584 - esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
585 - 30
586 - );
587 - wp_safe_redirect(esc_url($redirect_url));
588 - exit;
589 - }
590 -
591 - try {
592 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
593 -
594 - if ($total_pages === false || $total_pages < 1) {
595 - throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
596 - }
597 -
598 - // Use original filename as the source identifier
599 - $source_label = 'upload://' . $original_filename;
600 -
601 - $queue_id = 'pdf_' . md5($source_label . time());
602 -
603 - $pages = array();
604 - for ($i = 1; $i <= $total_pages; $i++) {
605 - $pages[] = array(
606 - 'pdf_path' => $pdf_path,
607 - 'pdf_url' => $source_label,
608 - 'page_number' => $i,
609 - 'total_pages' => $total_pages,
610 - );
611 - }
612 -
613 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
614 -
615 - if ($queued_count === 0) {
616 - wp_delete_file($pdf_path);
617 - throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
618 - }
619 -
620 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
621 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
622 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
623 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
624 - $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
625 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
626 -
627 - set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
628 - set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
629 -
630 - set_transient('mxchat_admin_notice_success',
631 - sprintf(
632 - esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
633 - esc_html($original_filename),
634 - $total_pages
635 - ),
636 - 30
637 - );
638 -
639 - } catch (Exception $e) {
640 - if (file_exists($pdf_path)) {
641 - wp_delete_file($pdf_path);
642 - }
643 - set_transient('mxchat_admin_notice_error',
644 - esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
645 - 30
646 - );
647 - }
648 -
649 - wp_safe_redirect(esc_url($redirect_url));
650 - exit;
651 -}
652 -
653 -/**
654 - * Validate PDF and count pages with multiple parser attempts
655 - */
656 -private function mxchat_validate_and_count_pdf_pages($pdf_path) {
657 - // Method 1: Try with Smalot PDF Parser (your current method)
658 - try {
659 - mxchat_load_pdf_parser();
660 - $parser = new \Smalot\PdfParser\Parser();
661 - $pdf = $parser->parseFile($pdf_path);
662 - $pages = $pdf->getPages();
663 - $page_count = count($pages);
664 -
665 - if ($page_count > 0) {
666 - //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
667 - return $page_count;
668 - }
669 - } catch (Exception $e) {
670 - //error_log('Smalot PDF parser failed: ' . $e->getMessage());
671 - }
672 -
673 - // Method 2: Try with pdfinfo command (if available)
674 - if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
675 - try {
676 - $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
677 - $output = shell_exec($command);
678 -
679 - if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
680 - $page_count = intval($matches[1]);
681 - if ($page_count > 0) {
682 - //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
683 - return $page_count;
684 - }
685 - }
686 - } catch (Exception $e) {
687 - //error_log('pdfinfo command failed: ' . $e->getMessage());
688 - }
689 - }
690 -
691 - // Method 3: Try to repair PDF and parse again
692 - try {
693 - $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
694 - if ($repaired_path && $repaired_path !== $pdf_path) {
695 - mxchat_load_pdf_parser();
696 - $parser = new \Smalot\PdfParser\Parser();
697 - $pdf = $parser->parseFile($repaired_path);
698 - $pages = $pdf->getPages();
699 - $page_count = count($pages);
700 -
701 - if ($page_count > 0) {
702 - // Replace original with repaired version
703 - copy($repaired_path, $pdf_path);
704 - unlink($repaired_path);
705 - //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
706 - return $page_count;
707 - }
708 -
709 - // Clean up repaired file if it didn't work
710 - unlink($repaired_path);
711 - }
712 - } catch (Exception $e) {
713 - //error_log('PDF repair attempt failed: ' . $e->getMessage());
714 - }
715 -
716 - // Method 4: Manual PDF structure analysis (basic page count)
717 - try {
718 - $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
719 - if ($page_count > 0) {
720 - //error_log('PDF page count determined manually: ' . $page_count . ' pages');
721 - return $page_count;
722 - }
723 - } catch (Exception $e) {
724 - //error_log('Manual PDF analysis failed: ' . $e->getMessage());
725 - }
726 -
727 - //error_log('All PDF parsing methods failed for: ' . $pdf_path);
728 - return false;
729 -}
730 -
731 -/**
732 - * Check if shell_exec is disabled
733 - */
734 -private function mxchat_is_shell_disabled() {
735 - $disabled = explode(',', ini_get('disable_functions'));
736 - return in_array('shell_exec', $disabled);
737 -}
738 -
739 -/**
740 - * Attempt to repair PDF using basic methods
741 - */
742 -private function mxchat_attempt_pdf_repair($pdf_path) {
743 - try {
744 - $content = file_get_contents($pdf_path);
745 - if (!$content) {
746 - return false;
747 - }
748 -
749 - // Check if PDF starts with proper header
750 - if (substr($content, 0, 4) !== '%PDF') {
751 - // Try to find PDF header in the content
752 - $header_pos = strpos($content, '%PDF');
753 - if ($header_pos !== false && $header_pos < 1024) {
754 - // Remove junk before PDF header
755 - $content = substr($content, $header_pos);
756 - $repaired_path = $pdf_path . '.repaired';
757 - file_put_contents($repaired_path, $content);
758 - return $repaired_path;
759 - }
760 - }
761 -
762 - // Check for EOF marker
763 - $content = rtrim($content);
764 - if (!preg_match('/%%EOF\s*$/', $content)) {
765 - // Add EOF marker if missing
766 - $content .= "\n%%EOF";
767 - $repaired_path = $pdf_path . '.repaired';
768 - file_put_contents($repaired_path, $content);
769 - return $repaired_path;
770 - }
771 -
772 - } catch (Exception $e) {
773 - //error_log('PDF repair error: ' . $e->getMessage());
774 - }
775 -
776 - return false;
777 -}
778 -
779 -/**
780 - * Manual PDF page counting by analyzing PDF structure
781 - */
782 -private function mxchat_manual_pdf_page_count($pdf_path) {
783 - try {
784 - $content = file_get_contents($pdf_path);
785 - if (!$content) {
786 - return 0;
787 - }
788 -
789 - // Method 1: Count /Type /Page objects
790 - $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
791 - if ($page_count > 0) {
792 - return $page_count;
793 - }
794 -
795 - // Method 2: Look for /Count in pages object
796 - if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
797 - return intval($matches[1]);
798 - }
799 -
800 - // Method 3: Count page references
801 - $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
802 - if ($page_count > 0) {
803 - return $page_count;
804 - }
805 -
806 - } catch (Exception $e) {
807 - //error_log('Manual PDF analysis error: ' . $e->getMessage());
808 - }
809 -
810 - return 0;
811 -}
812 -
813 -
814 -public function mxchat_save_inline_prompt() {
815 - // DEBUG: Log what we're receiving
816 - //error_log('=== MXCHAT DEBUG ===');
817 - //error_log('POST data: ' . print_r($_POST, true));
818 - //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
819 -
820 - // Check for nonce security
821 - check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
822 -
823 - // If we get here, nonce passed
824 - //error_log('Nonce verification PASSED');
825 -
826 - // Verify permissions
827 - if (!current_user_can('manage_options')) {
828 - wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
829 - return;
830 - }
831 -
832 - global $wpdb;
833 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
834 -
835 - // Validate and sanitize input data
836 - $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
837 - $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
838 - $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
839 -
840 - if ($prompt_id > 0 && !empty($article_content)) {
841 - // Re-generate the embedding vector for the updated content
842 - $embedding_vector = $this->mxchat_generate_embedding($article_content);
843 - if (is_array($embedding_vector)) {
844 - // Serialize the embedding vector before storing it
845 - $embedding_vector_serialized = serialize($embedding_vector);
846 - // Update the prompt in the database
847 - $updated = $wpdb->update(
848 - $table_name,
849 - array(
850 - 'article_content' => $article_content,
851 - 'embedding_vector' => $embedding_vector_serialized,
852 - 'source_url' => $article_url,
853 - ),
854 - array('id' => $prompt_id),
855 - array('%s', '%s', '%s'),
856 - array('%d')
857 - );
858 - if ($updated !== false) {
859 - wp_send_json_success();
860 - } else {
861 - MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
862 - wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
863 - }
864 - } else {
865 - MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
866 - wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
867 - }
868 - } else {
869 - wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
870 - }
871 -}
872 -
873 -
874 -/**
875 - * AJAX: Get full content for editing — reassembles chunks if needed.
876 - * Works for both WordPress DB and Pinecone entries.
877 - */
878 -public function ajax_mxchat_get_entry_content() {
879 - check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
880 -
881 - if ( ! current_user_can('manage_options') ) {
882 - wp_send_json_error( array( 'message' => 'Permission denied.' ) );
883 - }
884 -
885 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
886 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
887 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
888 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
889 -
890 - if ( $data_source === 'pinecone' ) {
891 - // Pinecone: fetch vectors by source_url, reassemble chunks
892 - $content = $this->get_pinecone_entry_content( $source_url, $entry_id, $bot_id );
893 - } else {
894 - // WordPress DB
895 - $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
896 - }
897 -
898 - if ( is_wp_error( $content ) ) {
899 - wp_send_json_error( array( 'message' => $content->get_error_message() ) );
900 - }
901 -
902 - wp_send_json_success( $content );
903 -}
904 -
905 -/**
906 - * Get content from WordPress DB — reassembles chunks by source_url.
907 - */
908 -private function get_wordpress_entry_content( $source_url, $entry_id ) {
909 - global $wpdb;
910 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
911 -
912 - // If we have a source_url, check for chunks
913 - if ( ! empty( $source_url ) && strpos( $source_url, 'mxchat://' ) !== 0 ) {
914 - $rows = $wpdb->get_results( $wpdb->prepare(
915 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
916 - $source_url
917 - ) );
918 -
919 - if ( $rows && count( $rows ) > 1 ) {
920 - // Multiple rows = chunked. Reassemble.
921 - $chunks = array();
922 - foreach ( $rows as $row ) {
923 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
924 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
925 - $chunks[ $index ] = $parsed['text'];
926 - }
927 - ksort( $chunks );
928 - return array(
929 - 'content' => implode( "\n\n", $chunks ),
930 - 'source_url' => $source_url,
931 - 'is_chunked' => true,
932 - 'chunk_count' => count( $chunks ),
933 - 'content_type' => $rows[0]->content_type,
934 - );
935 - } elseif ( $rows && count( $rows ) === 1 ) {
936 - $parsed = MxChat_Chunker::parse_stored_chunk( $rows[0]->article_content );
937 - return array(
938 - 'content' => $parsed['text'],
939 - 'source_url' => $source_url,
940 - 'entry_id' => $rows[0]->id,
941 - 'is_chunked' => false,
942 - 'content_type' => $rows[0]->content_type,
943 - );
944 - }
945 - }
946 -
947 - // Fallback: fetch by ID
948 - if ( $entry_id > 0 ) {
949 - $row = $wpdb->get_row( $wpdb->prepare(
950 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
951 - $entry_id
952 - ) );
953 - if ( $row ) {
954 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
955 - return array(
956 - 'content' => $parsed['text'],
957 - 'source_url' => $row->source_url,
958 - 'entry_id' => $row->id,
959 - 'is_chunked' => false,
960 - 'content_type' => $row->content_type,
961 - );
962 - }
963 - }
964 -
965 - return new WP_Error( 'not_found', 'Entry not found.' );
966 -}
967 -
968 -/**
969 - * Get content from Pinecone — fetches vectors by source_url, reassembles chunks.
970 - */
971 -private function get_pinecone_entry_content( $source_url, $entry_id, $bot_id ) {
972 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
973 - return new WP_Error( 'pinecone_unavailable', 'Pinecone manager not available.' );
974 - }
975 -
976 - // Get Pinecone config
977 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
978 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
979 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
980 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
981 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
982 - } else {
983 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
984 - $api_key = $bot_config['api_key'] ?? '';
985 - $host = $bot_config['host'] ?? '';
986 - $namespace = $bot_config['namespace'] ?? '';
987 - }
988 -
989 - if ( empty($host) || empty($api_key) ) {
990 - return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
991 - }
992 -
993 - // List vectors with the source_url prefix
994 - $base_id = md5( $source_url );
995 - $vector_ids = array( $base_id );
996 -
997 - // Find chunk vectors
998 - $list_url = "https://{$host}/vectors/list";
999 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1000 - if ( ! empty($namespace) ) {
1001 - $list_body['namespace'] = $namespace;
1002 - }
1003 -
1004 - $list_resp = wp_remote_post( $list_url, array(
1005 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1006 - 'body' => wp_json_encode( $list_body ),
1007 - 'timeout' => 15,
1008 - ) );
1009 -
1010 - if ( ! is_wp_error($list_resp) ) {
1011 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1012 - if ( ! empty($list_data['vectors']) ) {
1013 - foreach ( $list_data['vectors'] as $v ) {
1014 - $vector_ids[] = $v['id'];
1015 - }
1016 - }
1017 - }
1018 -
1019 - // Fetch vectors with metadata
1020 - $fetch_url = "https://{$host}/vectors/fetch";
1021 - $fetch_body = array( 'ids' => $vector_ids );
1022 - if ( ! empty($namespace) ) {
1023 - $fetch_body['namespace'] = $namespace;
1024 - }
1025 -
1026 - $fetch_resp = wp_remote_post( $fetch_url, array(
1027 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1028 - 'body' => wp_json_encode( $fetch_body ),
1029 - 'timeout' => 15,
1030 - ) );
1031 -
1032 - if ( is_wp_error($fetch_resp) ) {
1033 - return new WP_Error( 'pinecone_fetch', 'Failed to fetch from Pinecone.' );
1034 - }
1035 -
1036 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1037 - $vectors = $fetch_data['vectors'] ?? array();
1038 -
1039 - if ( empty($vectors) ) {
1040 - return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1041 - }
1042 -
1043 - // Reassemble chunks
1044 - $chunks = array();
1045 - $content_type = 'content';
1046 - foreach ( $vectors as $vid => $vector ) {
1047 - $meta = $vector['metadata'] ?? array();
1048 - $text = $meta['text'] ?? '';
1049 - $index = $meta['chunk_index'] ?? 0;
1050 - $content_type = $meta['type'] ?? 'content';
1051 - $chunks[ intval($index) ] = $text;
1052 - }
1053 - ksort( $chunks );
1054 -
1055 - return array(
1056 - 'content' => implode( "\n\n", $chunks ),
1057 - 'source_url' => $source_url,
1058 - 'is_chunked' => count($chunks) > 1,
1059 - 'chunk_count' => count($chunks),
1060 - 'content_type' => $content_type,
1061 - );
1062 -}
1063 -
1064 -/**
1065 - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1066 - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1067 - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1068 - */
1069 -public function ajax_mxchat_inspect_entry() {
1070 - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1071 -
1072 - if ( ! current_user_can('manage_options') ) {
1073 - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1074 - }
1075 -
1076 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1077 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1078 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1079 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1080 -
1081 - if ( $data_source === 'pinecone' ) {
1082 - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
1083 - } else {
1084 - $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1085 - }
1086 -
1087 - if ( is_wp_error( $result ) ) {
1088 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1089 - }
1090 -
1091 - wp_send_json_success( $result );
1092 -}
1093 -
1094 -/**
1095 - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1096 - * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1097 - * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1098 - */
1099 -private function inspect_wordpress_entry( $source_url, $entry_id ) {
1100 - global $wpdb;
1101 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1102 -
1103 - $rows = array();
1104 -
1105 - // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1106 - // Direct Content entries (the spec's manual-entry case), which share one
1107 - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1108 - // display key (invented by the table view for rows with no source_url) is
1109 - // excluded; those fall through to the entry_id lookup below.
1110 - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1111 - $rows = $wpdb->get_results( $wpdb->prepare(
1112 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1113 - $source_url
1114 - ) );
1115 - }
1116 -
1117 - // Fallback / manual "Direct Content" entries: fetch the single row by id.
1118 - if ( empty( $rows ) && $entry_id > 0 ) {
1119 - $row = $wpdb->get_row( $wpdb->prepare(
1120 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1121 - $entry_id
1122 - ) );
1123 - if ( $row ) {
1124 - $rows = array( $row );
1125 - }
1126 - }
1127 -
1128 - if ( empty( $rows ) ) {
1129 - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1130 - }
1131 -
1132 - $chunks = array();
1133 - $content_type = '';
1134 - foreach ( $rows as $row ) {
1135 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1136 - $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1137 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1138 - $content_type = $row->content_type;
1139 - $chunks[] = array(
1140 - 'index' => $index,
1141 - 'text' => $text,
1142 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1143 - 'row_id' => intval( $row->id ),
1144 - );
1145 - }
1146 -
1147 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1148 -
1149 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1150 -
1151 - return array(
1152 - 'store' => 'wordpress',
1153 - 'source_url' => $source_url,
1154 - 'content_type' => $content_type,
1155 - 'is_chunked' => count( $chunks ) > 1,
1156 - 'chunk_count' => count( $chunks ),
1157 - 'assembled' => $assembled,
1158 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1159 - 'chunks' => array_values( $chunks ),
1160 - // WP-DB storage carries no separate vector metadata; surface that fact
1161 - // rather than letting the owner guess (the spec's taxonomy question).
1162 - 'metadata' => array(),
1163 - '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'),
1164 - );
1165 -}
1166 -
1167 -/**
1168 - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1169 - * but keeps each vector's text + metadata instead of imploding, so the owner can
1170 - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1171 - * are present per chunk. READ-ONLY.
1172 - */
1173 -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1174 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1175 - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1176 - }
1177 -
1178 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1179 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1180 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1181 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1182 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1183 - } else {
1184 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1185 - $api_key = $bot_config['api_key'] ?? '';
1186 - $host = $bot_config['host'] ?? '';
1187 - $namespace = $bot_config['namespace'] ?? '';
1188 - }
1189 -
1190 - if ( empty($host) || empty($api_key) ) {
1191 - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1192 - }
1193 -
1194 - $base_id = md5( $source_url );
1195 - $vector_ids = array( $base_id );
1196 -
1197 - $list_url = "https://{$host}/vectors/list";
1198 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1199 - if ( ! empty($namespace) ) {
1200 - $list_body['namespace'] = $namespace;
1201 - }
1202 -
1203 - $list_resp = wp_remote_post( $list_url, array(
1204 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1205 - 'body' => wp_json_encode( $list_body ),
1206 - 'timeout' => 15,
1207 - ) );
1208 -
1209 - if ( ! is_wp_error($list_resp) ) {
1210 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1211 - if ( ! empty($list_data['vectors']) ) {
1212 - foreach ( $list_data['vectors'] as $v ) {
1213 - $vector_ids[] = $v['id'];
1214 - }
1215 - }
1216 - }
1217 -
1218 - $fetch_url = "https://{$host}/vectors/fetch";
1219 - $fetch_body = array( 'ids' => $vector_ids );
1220 - if ( ! empty($namespace) ) {
1221 - $fetch_body['namespace'] = $namespace;
1222 - }
1223 -
1224 - $fetch_resp = wp_remote_post( $fetch_url, array(
1225 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1226 - 'body' => wp_json_encode( $fetch_body ),
1227 - 'timeout' => 15,
1228 - ) );
1229 -
1230 - if ( is_wp_error($fetch_resp) ) {
1231 - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1232 - }
1233 -
1234 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1235 - $vectors = $fetch_data['vectors'] ?? array();
1236 -
1237 - if ( empty($vectors) ) {
1238 - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1239 - }
1240 -
1241 - // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1242 - // what is (and is NOT) stored per vector.
1243 - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1244 - $chunks = array();
1245 - $content_type = '';
1246 - foreach ( $vectors as $vid => $vector ) {
1247 - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1248 - $text = $meta['text'] ?? '';
1249 - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1250 - $content_type = $meta['type'] ?? $content_type;
1251 -
1252 - $clean_meta = array();
1253 - foreach ( $meta_fields as $field ) {
1254 - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1255 - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1256 - }
1257 - }
1258 -
1259 - $chunks[] = array(
1260 - 'index' => $index,
1261 - 'text' => $text,
1262 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1263 - 'vector_id' => (string) $vid,
1264 - 'metadata' => $clean_meta,
1265 - );
1266 - }
1267 -
1268 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1269 -
1270 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1271 -
1272 - return array(
1273 - 'store' => 'pinecone',
1274 - 'source_url' => $source_url,
1275 - 'content_type' => $content_type,
1276 - 'is_chunked' => count( $chunks ) > 1,
1277 - 'chunk_count' => count( $chunks ),
1278 - 'assembled' => $assembled,
1279 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1280 - 'chunks' => array_values( $chunks ),
1281 - 'metadata' => array(),
1282 - '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'),
1283 - );
1284 -}
1285 -
1286 -/**
1287 - * AJAX: Save edited content — re-chunks and re-embeds as needed.
1288 - * Works for both WordPress DB and Pinecone entries.
1289 - */
1290 -public function ajax_mxchat_save_entry_content() {
1291 - check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1292 -
1293 - if ( ! current_user_can('manage_options') ) {
1294 - wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1295 - }
1296 -
1297 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1298 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1299 - $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
1300 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1301 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1302 - $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
1303 -
1304 - if ( empty($content) ) {
1305 - wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
1306 - }
1307 -
1308 - // Get the embedding API key
1309 - $options = get_option('mxchat_options', array());
1310 - $api_key = '';
1311 -
1312 - if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
1313 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1314 - $api_key = $bot_options['api_key'] ?? '';
1315 - }
1316 - if ( empty($api_key) ) {
1317 - $api_key = $options['api_key'] ?? '';
1318 - }
1319 -
1320 - global $wpdb;
1321 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1322 -
1323 - // If source_url is empty but we have an entry_id, look it up
1324 - if ( empty($source_url) && $entry_id > 0 && $data_source === 'wordpress' ) {
1325 - $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1326 - if ( $row && ! empty($row->source_url) ) {
1327 - $source_url = $row->source_url;
1328 - }
1329 - }
1330 -
1331 - // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1332 - // so submit_content_to_db creates a replacement instead of a duplicate
1333 - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1334 - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1335 - if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
1336 - $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1337 - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1338 - // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1339 - if ( $is_legacy_manual ) {
1340 - $source_url = '';
1341 - }
1342 - }
1343 -
1344 - // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1345 - $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1346 -
1347 - // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1348 - $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1349 -
1350 - if ( is_wp_error($result) ) {
1351 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1352 - }
1353 -
1354 - wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
1355 -}
1356 -
1357 -public function mxchat_get_pdf_processing_status($pdf_url) {
1358 - $pdf_url = esc_url_raw($pdf_url);
1359 - $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
1360 -
1361 - if (!$status || !is_array($status)) {
1362 - return false;
1363 - }
1364 -
1365 - // Check for stalled processing (no updates for 5 minutes)
1366 - if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
1367 - $status['status'] = 'error';
1368 - $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
1369 -
1370 - // Save the updated status
1371 - set_transient(
1372 - sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
1373 - array_map('sanitize_text_field', $status),
1374 - DAY_IN_SECONDS
1375 - );
1376 - }
1377 -
1378 - $result = array(
1379 - 'total_pages' => absint($status['total_pages']),
1380 - 'processed_pages' => absint($status['processed_pages']),
1381 - 'failed_pages' => absint($status['failed_pages'] ?? 0),
1382 - 'percentage' => ($status['total_pages'] > 0)
1383 - ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
1384 - : 0,
1385 - 'status' => sanitize_text_field($status['status']),
1386 - 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1387 - 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
1388 - 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
1389 - );
1390 -
1391 - // Add error message if present
1392 - if (isset($status['error']) && !empty($status['error'])) {
1393 - $result['error'] = sanitize_text_field($status['error']);
1394 - }
1395 -
1396 - return $result;
1397 -}
1398 -
1399 -
1400 -public function mxchat_handle_sitemap_submission() {
1401 - // Check if the form was submitted and verify permissions
1402 - if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
1403 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
1404 - }
1405 -
1406 - // Verify nonce
1407 - check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
1408 -
1409 - // Validate URL
1410 - if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
1411 - set_transient('mxchat_admin_notice_error',
1412 - esc_html__('Please provide a valid URL.', 'mxchat'),
1413 - 30
1414 - );
1415 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1416 - exit;
1417 - }
1418 -
1419 - $submitted_url = esc_url_raw($_POST['sitemap_url']);
1420 -
1421 - // Convert Google Drive sharing URLs to direct download URLs
1422 - if ( strpos($submitted_url, 'drive.google.com') !== false ) {
1423 - $file_id = '';
1424 - if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
1425 - $file_id = $m[1];
1426 - } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
1427 - $file_id = $m[1];
1428 - }
1429 - if ( ! empty($file_id) ) {
1430 - $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
1431 - }
1432 - }
1433 -
1434 - // Get bot_id from form submission
1435 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1436 -
1437 - // Get bot-specific options and validate API key
1438 - $bot_options = $this->get_bot_options($bot_id);
1439 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1440 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
1441 -
1442 - if (strpos($selected_model, 'voyage') === 0) {
1443 - $api_key = $options['voyage_api_key'] ?? '';
1444 - $provider_name = 'Voyage AI';
1445 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
1446 - $api_key = $options['gemini_api_key'] ?? '';
1447 - $provider_name = 'Google Gemini';
1448 - } else {
1449 - $api_key = $options['api_key'] ?? '';
1450 - $provider_name = 'OpenAI';
1451 - }
1452 -
1453 - if (empty($api_key)) {
1454 - $error_message = sprintf(
1455 - esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
1456 - $provider_name
1457 - );
1458 - set_transient('mxchat_admin_notice_error', $error_message, 30);
1459 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1460 - exit;
1461 - }
1462 -
1463 - // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1464 - // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1465 - // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1466 - // from the site's own media library, which route through this same call).
1467 - // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1468 - // the browser-only Accept-Language fingerprint is dropped so it stays
1469 - // coherent with a bot identity.
1470 - $response = wp_remote_get($submitted_url, array(
1471 - 'timeout' => 30,
1472 - 'sslverify' => false,
1473 - 'user-agent' => mxchat_ingest_user_agent(),
1474 - 'headers' => array(
1475 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1476 - ),
1477 - ));
1478 -
1479 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1480 - $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1481 - set_transient('mxchat_admin_notice_error',
1482 - sprintf(
1483 - esc_html__('Failed to fetch the URL: %s', 'mxchat'),
1484 - esc_html($error_message)
1485 - ),
1486 - 30
1487 - );
1488 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1489 - exit;
1490 - }
1491 -
1492 - $content_type = wp_remote_retrieve_header($response, 'content-type');
1493 - $body_content = wp_remote_retrieve_body($response);
1494 -
1495 - if (empty($body_content)) {
1496 - set_transient('mxchat_admin_notice_error',
1497 - esc_html__('Empty response received from URL.', 'mxchat'),
1498 - 30
1499 - );
1500 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1501 - exit;
1502 - }
1503 -
1504 - // Handle PDF URL
1505 - if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
1506 - $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
1507 -
1508 - if ($result === 'queued') {
1509 - set_transient('mxchat_admin_notice_success',
1510 - esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
1511 - 30
1512 - );
1513 - } else {
1514 - set_transient('mxchat_admin_notice_error',
1515 - esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
1516 - 30
1517 - );
1518 - }
1519 -
1520 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1521 - exit;
1522 - }
1523 -
1524 - // Handle Sitemap XML
1525 - if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
1526 - libxml_use_internal_errors(true);
1527 - $xml = simplexml_load_string($body_content);
1528 - $xml_errors = libxml_get_errors();
1529 - libxml_clear_errors();
1530 -
1531 - if ($xml === false || !empty($xml_errors)) {
1532 - set_transient('mxchat_admin_notice_error',
1533 - esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
1534 - 30
1535 - );
1536 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1537 - exit;
1538 - }
1539 -
1540 - $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
1541 -
1542 - if ($result === 'queued') {
1543 - set_transient('mxchat_admin_notice_success',
1544 - esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1545 - 30
1546 - );
1547 - } else {
1548 - set_transient('mxchat_admin_notice_error',
1549 - esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
1550 - 30
1551 - );
1552 - }
1553 -
1554 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1555 - exit;
1556 - }
1557 -
1558 - // Handle Regular URL (single page)
1559 - $page_content = $this->mxchat_extract_main_content($body_content);
1560 - $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
1561 -
1562 - //error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
1563 - //error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
1564 - //error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
1565 - //error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
1566 -
1567 - if (empty($sanitized_content)) {
1568 - set_transient('mxchat_admin_notice_error',
1569 - esc_html__('No valid content found on the provided URL.', 'mxchat'),
1570 - 30
1571 - );
1572 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1573 - exit;
1574 - }
1575 -
1576 - // For single URLs, process immediately using submit_content_to_db
1577 - // This handles chunking automatically for large content
1578 - $db_result = MxChat_Utils::submit_content_to_db(
1579 - $sanitized_content,
1580 - $submitted_url,
1581 - $api_key,
1582 - null,
1583 - $bot_id,
1584 - 'url' // content_type
1585 - );
1586 -
1587 - if (is_wp_error($db_result)) {
1588 - $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1589 - set_transient('mxchat_admin_notice_error', $error_message, 30);
1590 - } else {
1591 - $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1592 - set_transient('mxchat_admin_notice_success', $success_message, 30);
1593 - }
1594 -
1595 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1596 - exit;
1597 -}
1598 -
1599 -
1600 -public function mxchat_get_single_url_status() {
1601 - $status = get_transient('mxchat_single_url_status');
1602 - if (!$status) {
1603 - return null;
1604 - }
1605 -
1606 - // Add human-readable time
1607 - if (isset($status['timestamp'])) {
1608 - $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1609 - }
1610 -
1611 - return $status;
1612 -}
1613 -
1614 -public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1615 - if (!current_user_can('manage_options')) {
1616 - return false;
1617 - }
1618 -
1619 - try {
1620 - $sitemap_url = esc_url_raw($sitemap_url);
1621 -
1622 - if (!$xml || !is_object($xml)) {
1623 - throw new Exception(__('Invalid XML object provided', 'mxchat'));
1624 - }
1625 -
1626 - // Get bot-specific embedding API for validation
1627 - $bot_options = $this->get_bot_options($bot_id);
1628 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1629 -
1630 - // Test the embedding API before processing
1631 - $test_phrase = "Test embedding generation for MxChat";
1632 - $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1633 -
1634 - if (is_string($test_result)) {
1635 - throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1636 - }
1637 -
1638 - if (!is_array($test_result)) {
1639 - throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1640 - }
1641 -
1642 - // Extract URLs from sitemap
1643 - $urls = array();
1644 - foreach ($xml->url as $url_element) {
1645 - $url = esc_url_raw((string)$url_element->loc);
1646 - if ($url) {
1647 - $urls[] = array('url' => $url);
1648 - }
1649 - }
1650 -
1651 - $total_urls = count($urls);
1652 -
1653 - if ($total_urls < 1) {
1654 - throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1655 - }
1656 -
1657 - // Create unique queue ID
1658 - $queue_id = 'sitemap_' . md5($sitemap_url . time());
1659 -
1660 - // Add URLs to queue
1661 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1662 -
1663 - if ($queued_count === 0) {
1664 - throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1665 - }
1666 -
1667 - // Store queue metadata
1668 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1669 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1670 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1671 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1672 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1673 -
1674 - // Store queue ID in transient for status tracking
1675 - set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
1676 - set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
1677 -
1678 - return 'queued';
1679 -
1680 - } catch (Exception $e) {
1681 - $error_message = $e->getMessage();
1682 - //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
1683 -
1684 - return $error_message;
1685 - }
1686 -
1687 -}
1688 -
1689 -/**
1690 - * Remove shortcode tags but preserve the content inside them
1691 - * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
1692 - *
1693 - * @param string $content The content containing shortcodes
1694 - * @return string Content with shortcode tags removed but inner content preserved
1695 - */
1696 -private function strip_shortcode_tags_preserve_content($content) {
1697 - // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
1698 - // Content between tags is inherently preserved since only brackets are targeted
1699 - $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
1700 - return ($result !== null) ? $result : $content;
1701 -}
1702 -
1703 -public function mxchat_sanitize_content_for_api($content) {
1704 - //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
1705 -
1706 - // Remove shortcode tags but PRESERVE content inside them
1707 - $content = $this->strip_shortcode_tags_preserve_content($content);
1708 -
1709 - // Remove script, style tags, and HTML comments
1710 - $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
1711 - $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
1712 - $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
1713 -
1714 - // Remove all HTML tags and decode HTML entities
1715 - $content = wp_strip_all_tags($content);
1716 - $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
1717 -
1718 - // Normalize whitespace but preserve paragraph breaks
1719 - // First, normalize line endings to \n
1720 - $content = str_replace(["\r\n", "\r"], "\n", $content);
1721 - // Replace multiple spaces/tabs with single space, but preserve newlines
1722 - $content = preg_replace('/[ \t]+/', ' ', $content);
1723 - // Replace 3+ newlines with 2 newlines (max 2 blank lines)
1724 - $content = preg_replace('/\n{3,}/', "\n\n", $content);
1725 - // Trim each line
1726 - $lines = explode("\n", $content);
1727 - $lines = array_map('trim', $lines);
1728 - $content = implode("\n", $lines);
1729 - // Final trim
1730 - $content = trim($content);
1731 -
1732 - // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
1733 - $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
1734 -
1735 - // Remove NULL bytes which can cause database errors
1736 - $content = str_replace("\0", "", $content);
1737 -
1738 - // Ensure valid UTF-8 encoding
1739 - $content = wp_check_invalid_utf8($content);
1740 -
1741 - // Remove any extremely long strings without spaces (often garbage)
1742 - $content = preg_replace('/\S{300,}/', ' ', $content);
1743 -
1744 - // Replace problematic characters that often cause database issues
1745 - $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
1746 -
1747 - // Replace any remaining potentially problematic characters with spaces
1748 - // BUT preserve newlines by temporarily replacing them
1749 - $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
1750 - $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
1751 - $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
1752 -
1753 - // Limit to reasonable length if needed
1754 - $max_length = 65000; // Just under MySQL TEXT field limit
1755 - if (strlen($content) > $max_length) {
1756 - $content = substr($content, 0, $max_length);
1757 - }
1758 -
1759 - //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
1760 - return $content;
1761 -}
1762 -public function mxchat_extract_main_content($html) {
1763 - if (empty($html)) {
1764 - return '';
1765 - }
1766 - try {
1767 - $dom = new DOMDocument;
1768 - libxml_use_internal_errors(true); // Suppress HTML parsing errors
1769 - @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
1770 - $xpath = new DOMXPath($dom);
1771 -
1772 - // For debugging purposes
1773 - $debugEnabled = true; // Set to true to enable debugging output
1774 - $debug = function($message) use ($debugEnabled) {
1775 - if ($debugEnabled) {
1776 - //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
1777 - }
1778 - };
1779 -
1780 - // Direct targeting for Gerow theme posts
1781 - $post_text = $xpath->query('//div[contains(@class, "post-text")]');
1782 - if ($post_text && $post_text->length > 0) {
1783 - $debug("Found post-text directly");
1784 - $content = '';
1785 - foreach ($post_text as $node) {
1786 - $content .= $dom->saveHTML($node);
1787 - }
1788 - if (!empty($content)) {
1789 - $debug("Returning post-text content");
1790 - return $content;
1791 - }
1792 - }
1793 -
1794 - // Try to get the blog details content which contains the post-text
1795 - $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
1796 - if ($blog_details && $blog_details->length > 0) {
1797 - $debug("Found blog-details-content");
1798 - $content = '';
1799 - foreach ($blog_details as $node) {
1800 - $content .= $dom->saveHTML($node);
1801 - }
1802 - if (!empty($content)) {
1803 - $debug("Returning blog-details-content");
1804 - return $content;
1805 - }
1806 - }
1807 -
1808 - // Try to get the article which contains the blog details
1809 - $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
1810 - if ($article && $article->length > 0) {
1811 - $debug("Found article with blog-details-wrap");
1812 - $content = '';
1813 - foreach ($article as $node) {
1814 - $content .= $dom->saveHTML($node);
1815 - }
1816 - if (!empty($content)) {
1817 - $debug("Returning article content");
1818 - return $content;
1819 - }
1820 - }
1821 -
1822 - // Try even broader with the blog-item-wrap
1823 - $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
1824 - if ($blog_item && $blog_item->length > 0) {
1825 - $debug("Found blog-item-wrap");
1826 - $content = '';
1827 - foreach ($blog_item as $node) {
1828 - $content .= $dom->saveHTML($node);
1829 - }
1830 - if (!empty($content)) {
1831 - $debug("Returning blog-item-wrap content");
1832 - return $content;
1833 - }
1834 - }
1835 -
1836 - // Specific Gerow theme path
1837 - $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
1838 - if ($gerow_path && $gerow_path->length > 0) {
1839 - $debug("Found Gerow theme path to post-text");
1840 - $content = '';
1841 - foreach ($gerow_path as $node) {
1842 - $content .= $dom->saveHTML($node);
1843 - }
1844 - if (!empty($content)) {
1845 - $debug("Returning Gerow post-text content");
1846 - return $content;
1847 - }
1848 - }
1849 -
1850 - // Generic blog post selectors
1851 - $selectors = [
1852 - // Blog post specific selectors
1853 - '//div[contains(@class, "post-text")]',
1854 - '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
1855 - '//div[contains(@class, "blog-details-content")]',
1856 - '//article[contains(@class, "blog-details-wrap")]',
1857 - '//div[contains(@class, "entry-content")]',
1858 - '//div[contains(@class, "blog-content")]',
1859 - '//div[contains(@class, "blog-item-wrap")]',
1860 -
1861 - // More general content selectors
1862 - '//div[contains(@class, "page__content")]',
1863 - '//div[contains(@class, "elementor-widget-container")]',
1864 - '//div[contains(@class, "elementor-text-editor")]',
1865 - '//div[contains(@class, "elementor-widget-text-editor")]',
1866 - '//*[contains(@class, "entry-content")]',
1867 - '//*[contains(@class, "post-content")]',
1868 - '//*[contains(@class, "article-content")]',
1869 - '//*[@id="content"]',
1870 - '//*[@id="main-content"]',
1871 - '//section[contains(@class, "blog-area")]',
1872 - '//article',
1873 - '//main',
1874 - '//div[contains(@class, "content")]'
1875 - ];
1876 -
1877 - // First handle Elementor content - get only leaf widget containers to avoid duplicates
1878 - $debug("Checking for Elementor content");
1879 - // Get widget containers that are direct children of widgets (not nested inside other widget containers)
1880 - $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
1881 - if ($elementor_widgets && $elementor_widgets->length > 0) {
1882 - $debug("Found Elementor widgets");
1883 - $seen_content = array(); // Track seen content to avoid duplicates
1884 - $combined_content = '';
1885 - foreach ($elementor_widgets as $widget) {
1886 - $widget_content = $dom->saveHTML($widget);
1887 - if (!empty($widget_content)) {
1888 - // Create a hash of the content to detect duplicates
1889 - $content_hash = md5($widget_content);
1890 - if (!isset($seen_content[$content_hash])) {
1891 - $seen_content[$content_hash] = true;
1892 - $combined_content .= $widget_content;
1893 - }
1894 - }
1895 - }
1896 - if (!empty($combined_content)) {
1897 - $debug("Returning Elementor content");
1898 - return $combined_content;
1899 - }
1900 - }
1901 -
1902 - // Try standard selectors one by one
1903 - foreach ($selectors as $selector) {
1904 - $debug("Trying selector: " . $selector);
1905 - $nodes = $xpath->query($selector);
1906 - if ($nodes && $nodes->length > 0) {
1907 - $debug("Found " . $nodes->length . " matches for selector: " . $selector);
1908 - // Only take the FIRST matching node to avoid duplicate content
1909 - // (pages often have nested or multiple containers with same class)
1910 - $content = $dom->saveHTML($nodes->item(0));
1911 - if (!empty($content)) {
1912 - $debug("Returning content from selector: " . $selector . " (first match only)");
1913 - return $content;
1914 - }
1915 - }
1916 - }
1917 -
1918 - // Manual regex fallback for post-text if DOM methods fail
1919 - $debug("Trying regex fallback");
1920 - if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
1921 - $debug("Found post-text via regex");
1922 - return '<div class="post-text">' . $matches[1] . '</div>';
1923 - }
1924 -
1925 - // Try to extract the blog section as a whole
1926 - $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
1927 - if ($blog_section && $blog_section->length > 0) {
1928 - $debug("Found blog-area section");
1929 - $content = '';
1930 - foreach ($blog_section as $node) {
1931 - $content .= $dom->saveHTML($node);
1932 - }
1933 - if (!empty($content)) {
1934 - $debug("Returning blog-area section content");
1935 - return $content;
1936 - }
1937 - }
1938 -
1939 - // Generic container selectors for non-CMS sites (like .asp pages)
1940 - $debug("Trying generic container selectors");
1941 - $generic_selectors = [
1942 - '//div[@id="main"]',
1943 - '//div[@id="wrapper"]',
1944 - '//div[@id="page"]',
1945 - '//div[@id="site-content"]',
1946 - '//div[contains(@class, "main-content")]',
1947 - '//div[contains(@class, "page-content")]',
1948 - '//div[contains(@class, "site-content")]',
1949 - ];
1950 -
1951 - foreach ($generic_selectors as $selector) {
1952 - $debug("Trying generic selector: " . $selector);
1953 - $nodes = $xpath->query($selector);
1954 - if ($nodes && $nodes->length > 0) {
1955 - $content = $dom->saveHTML($nodes->item(0));
1956 - if (!empty($content)) {
1957 - $debug("Returning content from generic selector: " . $selector);
1958 - return $content;
1959 - }
1960 - }
1961 - }
1962 -
1963 - // Paragraph-based content detection - find regions with substantial text
1964 - $debug("Trying paragraph-based content detection");
1965 - $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
1966 - if ($paragraphs && $paragraphs->length >= 3) {
1967 - $debug("Found " . $paragraphs->length . " substantial paragraphs");
1968 - // Collect all substantial paragraphs and their content
1969 - $paragraph_content = '';
1970 - foreach ($paragraphs as $p) {
1971 - $paragraph_content .= $dom->saveHTML($p) . "\n";
1972 - }
1973 - if (!empty($paragraph_content)) {
1974 - $debug("Returning paragraph-based content");
1975 - return $paragraph_content;
1976 - }
1977 - }
1978 -
1979 - // Improved body fallback - strip nav/header/footer elements first
1980 - $debug("Using improved body fallback");
1981 - $body = $dom->getElementsByTagName('body');
1982 - if ($body->length > 0) {
1983 - // Clone the body to avoid modifying the original DOM
1984 - $body_clone = $body->item(0)->cloneNode(true);
1985 -
1986 - // Remove common non-content elements by tag name
1987 - $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
1988 - foreach ($remove_tags as $tag) {
1989 - $elements = $body_clone->getElementsByTagName($tag);
1990 - // Iterate backwards to safely remove elements
1991 - for ($i = $elements->length - 1; $i >= 0; $i--) {
1992 - $el = $elements->item($i);
1993 - if ($el && $el->parentNode) {
1994 - $el->parentNode->removeChild($el);
1995 - }
1996 - }
1997 - }
1998 -
1999 - // Remove elements with common non-content class names using XPath on the cloned body
2000 - $temp_dom = new DOMDocument();
2001 - @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
2002 - $temp_xpath = new DOMXPath($temp_dom);
2003 -
2004 - $remove_class_patterns = [
2005 - '//*[contains(@class, "nav")]',
2006 - '//*[contains(@class, "menu")]',
2007 - '//*[contains(@class, "sidebar")]',
2008 - '//*[contains(@class, "footer")]',
2009 - '//*[contains(@class, "header")]',
2010 - '//*[contains(@id, "nav")]',
2011 - '//*[contains(@id, "menu")]',
2012 - '//*[contains(@id, "sidebar")]',
2013 - '//*[contains(@id, "footer")]',
2014 - '//*[contains(@id, "header")]',
2015 - ];
2016 -
2017 - foreach ($remove_class_patterns as $pattern) {
2018 - $elements = $temp_xpath->query($pattern);
2019 - if ($elements) {
2020 - for ($i = $elements->length - 1; $i >= 0; $i--) {
2021 - $el = $elements->item($i);
2022 - if ($el && $el->parentNode) {
2023 - $el->parentNode->removeChild($el);
2024 - }
2025 - }
2026 - }
2027 - }
2028 -
2029 - $cleaned_content = $temp_dom->saveHTML();
2030 - if (!empty($cleaned_content)) {
2031 - $debug("Returning cleaned body content");
2032 - return $cleaned_content;
2033 - }
2034 - }
2035 -
2036 - // Last resort: return the original HTML
2037 - $debug("Returning original HTML");
2038 - return $html;
2039 - } catch (Exception $e) {
2040 - //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
2041 - return $html; // Return original HTML if parsing fails
2042 - } finally {
2043 - libxml_clear_errors();
2044 - }
2045 -}
2046 -public function mxchat_get_sitemap_processing_status($sitemap_url) {
2047 - $sitemap_url = esc_url_raw($sitemap_url);
2048 - $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
2049 - $status = get_transient($status_key);
2050 -
2051 - if (!$status || !is_array($status)) {
2052 - return false;
2053 - }
2054 -
2055 - // Auto-complete check: if all URLs are processed but status isn't complete
2056 - if (isset($status['processed_urls']) && isset($status['total_urls']) &&
2057 - $status['processed_urls'] >= $status['total_urls'] &&
2058 - isset($status['status']) && $status['status'] !== 'complete' &&
2059 - $status['status'] !== 'error') {
2060 -
2061 - // Mark as complete
2062 - $status['status'] = 'complete';
2063 - $status['processed_urls'] = $status['total_urls']; // Ensure exact match
2064 -
2065 - // Update the transient with the corrected status
2066 - set_transient($status_key, $status, DAY_IN_SECONDS);
2067 - }
2068 -
2069 - return array(
2070 - 'total_urls' => absint($status['total_urls']),
2071 - 'processed_urls' => absint($status['processed_urls']),
2072 - 'failed_urls' => absint($status['failed_urls'] ?? 0),
2073 - 'percentage' => ($status['total_urls'] > 0)
2074 - ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
2075 - : 0,
2076 - 'status' => sanitize_text_field($status['status']),
2077 - 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
2078 - 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
2079 - 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
2080 - 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
2081 - );
2082 -}
2083 -
2084 -public function mxchat_ajax_get_status_updates() {
2085 - try {
2086 - // Verify the request
2087 - check_ajax_referer('mxchat_status_nonce', 'nonce');
2088 -
2089 - // Get active queue IDs
2090 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2091 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2092 -
2093 - $sitemap_status = false;
2094 - $pdf_status = false;
2095 -
2096 - // Get sitemap queue status
2097 - if ($sitemap_queue_id) {
2098 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2099 - }
2100 -
2101 - // Get PDF queue status
2102 - if ($pdf_queue_id) {
2103 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2104 - }
2105 -
2106 - $is_active_processing =
2107 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2108 - ($pdf_status && $pdf_status['status'] === 'processing');
2109 -
2110 - // Return JSON response with the status data
2111 - wp_send_json(array(
2112 - 'pdf_status' => $pdf_status,
2113 - 'sitemap_status' => $sitemap_status,
2114 - 'is_processing' => $is_active_processing,
2115 - 'sitemap_queue_id' => $sitemap_queue_id,
2116 - 'pdf_queue_id' => $pdf_queue_id
2117 - ));
2118 -
2119 - } catch (Exception $e) {
2120 - //error_log('MxChat Status Update Error: ' . $e->getMessage());
2121 -
2122 - wp_send_json_error(array(
2123 - 'message' => 'Error getting status updates: ' . $e->getMessage(),
2124 - 'status' => 'error'
2125 - ));
2126 - }
2127 -}
2128 -
2129 -/**
2130 - * Helper function to get queue status data
2131 - */
2132 -private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
2133 - global $wpdb;
2134 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2135 -
2136 - // Get counts by status
2137 - $counts = $wpdb->get_results($wpdb->prepare(
2138 - "SELECT status, COUNT(*) as count
2139 - FROM $table_name
2140 - WHERE queue_id = %s
2141 - GROUP BY status",
2142 - $queue_id
2143 - ), OBJECT_K);
2144 -
2145 - $total = 0;
2146 - $completed = 0;
2147 - $failed = 0;
2148 - $processing = 0;
2149 - $pending = 0;
2150 -
2151 - foreach ($counts as $status => $data) {
2152 - $count = absint($data->count);
2153 - $total += $count;
2154 -
2155 - switch ($status) {
2156 - case 'completed':
2157 - $completed = $count;
2158 - break;
2159 - case 'failed':
2160 - $failed = $count;
2161 - break;
2162 - case 'processing':
2163 - $processing = $count;
2164 - break;
2165 - case 'pending':
2166 - $pending = $count;
2167 - break;
2168 - }
2169 - }
2170 -
2171 - if ($total === 0) {
2172 - return false;
2173 - }
2174 -
2175 - // Calculate percentage
2176 - $percentage = round((($completed + $failed) / $total) * 100);
2177 -
2178 - // Get failed items details (limit to 50)
2179 - $failed_items = array();
2180 - if ($failed > 0) {
2181 - $failed_results = $wpdb->get_results($wpdb->prepare(
2182 - "SELECT item_type, item_data, error_message, attempts, completed_at
2183 - FROM $table_name
2184 - WHERE queue_id = %s
2185 - AND status = 'failed'
2186 - AND attempts >= max_attempts
2187 - ORDER BY id DESC
2188 - LIMIT 50",
2189 - $queue_id
2190 - ));
2191 -
2192 - foreach ($failed_results as $item) {
2193 - $data = json_decode($item->item_data, true);
2194 - $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
2195 -
2196 - $failed_items[] = array(
2197 - 'url' => $url,
2198 - 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
2199 - 'error' => $item->error_message,
2200 - 'retries' => $item->attempts,
2201 - 'time' => strtotime($item->completed_at)
2202 - );
2203 - }
2204 - }
2205 -
2206 - // Get queue metadata
2207 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
2208 -
2209 - // Determine if queue is complete
2210 - $is_complete = ($pending === 0 && $processing === 0);
2211 -
2212 - // Get last update time
2213 - $last_update = $wpdb->get_var($wpdb->prepare(
2214 - "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
2215 - FROM $table_name
2216 - WHERE queue_id = %s",
2217 - $queue_id
2218 - ));
2219 -
2220 - $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
2221 -
2222 - // Format based on type
2223 - if ($type === 'pdf') {
2224 - return array(
2225 - 'total_pages' => $total,
2226 - 'processed_pages' => $completed + $failed,
2227 - 'failed_pages' => $failed,
2228 - 'percentage' => $percentage,
2229 - 'status' => $is_complete ? 'complete' : 'processing',
2230 - 'last_update' => $last_update_text,
2231 - 'failed_pages_list' => $failed_items,
2232 - 'pdf_url' => $source_url,
2233 - 'queue_id' => $queue_id
2234 - );
2235 - } else {
2236 - return array(
2237 - 'total_urls' => $total,
2238 - 'processed_urls' => $completed + $failed,
2239 - 'failed_urls' => $failed,
2240 - 'percentage' => $percentage,
2241 - 'status' => $is_complete ? 'complete' : 'processing',
2242 - 'last_update' => $last_update_text,
2243 - 'failed_urls_list' => $failed_items,
2244 - 'sitemap_url' => $source_url,
2245 - 'queue_id' => $queue_id
2246 - );
2247 - }
2248 -}
2249 -
2250 -/**
2251 - * Public method to get processing status for both sitemap and PDF queues
2252 - * Used by admin pages to display processing status
2253 - *
2254 - * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
2255 - */
2256 -public function mxchat_get_processing_statuses() {
2257 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2258 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2259 -
2260 - $sitemap_status = false;
2261 - $pdf_status = false;
2262 -
2263 - if ($sitemap_queue_id) {
2264 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2265 - }
2266 -
2267 - if ($pdf_queue_id) {
2268 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2269 - }
2270 -
2271 - $is_processing =
2272 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2273 - ($pdf_status && $pdf_status['status'] === 'processing');
2274 -
2275 - return array(
2276 - 'sitemap_status' => $sitemap_status,
2277 - 'pdf_status' => $pdf_status,
2278 - 'is_processing' => $is_processing
2279 - );
2280 -}
2281 -
2282 -/**
2283 - * AJAX handler to get recent knowledge entries for real-time table updates
2284 - * UPDATED: Now supports both WordPress DB and Pinecone data sources
2285 - */
2286 -public function ajax_mxchat_get_recent_entries() {
2287 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2288 -
2289 - if (!current_user_can('manage_options')) {
2290 - wp_send_json_error(array('message' => 'Unauthorized'));
2291 - return;
2292 - }
2293 -
2294 - global $wpdb;
2295 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2296 -
2297 - // Get parameters
2298 - $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
2299 - $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
2300 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2301 -
2302 - // Check if Pinecone is enabled for this bot
2303 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2304 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2305 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2306 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2307 -
2308 - if ($use_pinecone && $has_pinecone_api) {
2309 - // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
2310 - // Use mxchat_fetch_pinecone_records which returns total_unique_entries
2311 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
2312 - $total_count = $records['total'] ?? 0;
2313 -
2314 - // For Pinecone, we don't return individual entries during polling
2315 - // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
2316 - // We just return the updated count
2317 - wp_send_json_success(array(
2318 - 'entries' => array(),
2319 - 'total_count' => absint($total_count),
2320 - 'max_id' => $last_id,
2321 - 'data_source' => 'pinecone'
2322 - ));
2323 - return;
2324 - }
2325 -
2326 - // WORDPRESS DB DATA SOURCE
2327 - // Build query to get entries newer than last_id
2328 - $where_clauses = array('1=1');
2329 - $where_values = array();
2330 -
2331 - if ($last_id > 0) {
2332 - $where_clauses[] = 'id > %d';
2333 - $where_values[] = $last_id;
2334 - }
2335 -
2336 - // Note: WordPress DB table doesn't have bot_id column
2337 - // Multi-bot filtering is handled via Pinecone namespaces
2338 -
2339 - $where_sql = implode(' AND ', $where_clauses);
2340 -
2341 - // Get recent entries
2342 - $query = "SELECT id, article_content, source_url, timestamp
2343 - FROM $table_name
2344 - WHERE $where_sql
2345 - ORDER BY id DESC
2346 - LIMIT %d";
2347 -
2348 - $where_values[] = $limit;
2349 -
2350 - $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
2351 -
2352 - // Get total count of GROUPED entries (by source_url) - matches pagination display
2353 - // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
2354 - $total_count = $wpdb->get_var(
2355 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2356 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2357 - );
2358 -
2359 - // Format entries for response
2360 - $formatted_entries = array();
2361 - $preview_length = 150;
2362 - foreach ($entries as $entry) {
2363 - // Parse chunk metadata using the proper chunker method (same as initial page load)
2364 - if (class_exists('MxChat_Chunker')) {
2365 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
2366 - $display_content = $chunk_meta['text'];
2367 - $chunk_metadata = $chunk_meta['metadata'];
2368 - } else {
2369 - $display_content = $entry->article_content;
2370 - $chunk_metadata = array();
2371 - }
2372 -
2373 - $content_preview = mb_strlen($display_content) > $preview_length
2374 - ? mb_substr($display_content, 0, $preview_length) . '...'
2375 - : $display_content;
2376 -
2377 - $formatted_entries[] = array(
2378 - 'id' => $entry->id,
2379 - 'preview' => esc_html($content_preview),
2380 - 'full_content' => wp_kses_post(wpautop($display_content)),
2381 - 'content_length' => mb_strlen($display_content),
2382 - 'preview_length' => $preview_length,
2383 - 'source_url' => $entry->source_url,
2384 - 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2385 - 'chunk_metadata' => $chunk_metadata,
2386 - 'bot_id' => $entry->bot_id ?? 'default',
2387 - 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2388 - 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2389 - );
2390 - }
2391 -
2392 - wp_send_json_success(array(
2393 - 'entries' => $formatted_entries,
2394 - 'total_count' => absint($total_count),
2395 - 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
2396 - 'data_source' => 'wordpress'
2397 - ));
2398 -}
2399 -
2400 -/**
2401 - * Get Pinecone total count from stats API
2402 - * Helper function for ajax_mxchat_get_recent_entries
2403 - */
2404 -private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
2405 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2406 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2407 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2408 -
2409 - if (empty($api_key) || empty($host)) {
2410 - return 0;
2411 - }
2412 -
2413 - try {
2414 - $stats_url = "https://{$host}/describe_index_stats";
2415 -
2416 - $response = wp_remote_post($stats_url, array(
2417 - 'headers' => array(
2418 - 'Api-Key' => $api_key,
2419 - 'Content-Type' => 'application/json'
2420 - ),
2421 - 'body' => '{}',
2422 - 'timeout' => 10
2423 - ));
2424 -
2425 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2426 - $body = wp_remote_retrieve_body($response);
2427 - $stats_data = json_decode($body, true);
2428 -
2429 - // If namespace is specified, get count from that specific namespace
2430 - if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
2431 - return intval($stats_data['namespaces'][$namespace]['vectorCount']);
2432 - }
2433 -
2434 - // If no namespace specified or namespace not found in response, use total
2435 - return intval($stats_data['totalVectorCount'] ?? 0);
2436 - }
2437 -
2438 - return 0;
2439 -
2440 - } catch (Exception $e) {
2441 - return 0;
2442 - }
2443 -}
2444 -
2445 -/**
2446 - * AJAX handler to refresh Pinecone entries table via AJAX
2447 - * Returns the table HTML for updating the UI without a full page reload
2448 - */
2449 -public function ajax_mxchat_refresh_pinecone_entries() {
2450 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2451 -
2452 - if (!current_user_can('manage_options')) {
2453 - wp_send_json_error(array('message' => 'Unauthorized'));
2454 - return;
2455 - }
2456 -
2457 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2458 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2459 - $per_page = 25;
2460 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2461 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2462 -
2463 - // Get Pinecone manager and options
2464 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2465 - if (!$pinecone_manager) {
2466 - wp_send_json_error(array('message' => 'Pinecone manager not available'));
2467 - return;
2468 - }
2469 -
2470 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
2471 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2472 - $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2473 -
2474 - if (!$use_pinecone || empty($pinecone_api_key)) {
2475 - wp_send_json_error(array('message' => 'Pinecone not configured'));
2476 - return;
2477 - }
2478 -
2479 - // Fetch records from Pinecone
2480 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
2481 - $prompts = $records['data'] ?? array();
2482 - $total_records = $records['total'] ?? 0;
2483 -
2484 - // Preprocess Pinecone records — set chunk_metadata and display_content
2485 - // (matches admin-knowledge-page.php preprocessing)
2486 - foreach ($prompts as $prompt) {
2487 - if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
2488 - $prompt->chunk_metadata = array(
2489 - 'chunk_index' => intval($prompt->chunk_index),
2490 - 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
2491 - 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
2492 - 'source_url' => $prompt->source_url ?? ''
2493 - );
2494 - $prompt->display_content = $prompt->article_content;
2495 - } else {
2496 - $prompt->chunk_metadata = array();
2497 - $prompt->display_content = $prompt->article_content ?? '';
2498 - }
2499 - }
2500 -
2501 - // Group prompts by source_url
2502 - $grouped_prompts = array();
2503 - foreach ($prompts as $prompt) {
2504 - $source_url = '';
2505 - if (!empty($prompt->chunk_metadata['source_url'])) {
2506 - $source_url = $prompt->chunk_metadata['source_url'];
2507 - } elseif (!empty($prompt->source_url)) {
2508 - $source_url = $prompt->source_url;
2509 - }
2510 -
2511 - if (!empty($source_url)) {
2512 - if (!isset($grouped_prompts[$source_url])) {
2513 - $grouped_prompts[$source_url] = array();
2514 - }
2515 - $grouped_prompts[$source_url][] = $prompt;
2516 - } else {
2517 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2518 - }
2519 - }
2520 -
2521 - // Sort each group by chunk_index
2522 - foreach ($grouped_prompts as $source_url => &$group) {
2523 - usort($group, function($a, $b) {
2524 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2525 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2526 - return $index_a - $index_b;
2527 - });
2528 - }
2529 - unset($group);
2530 -
2531 - // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
2532 - ob_start();
2533 - $display_index = 0;
2534 - $current_page = $page;
2535 - $data_source = 'pinecone';
2536 - $current_bot_id = $bot_id;
2537 - $preview_length = 150;
2538 -
2539 - if (empty($grouped_prompts)) {
2540 - echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2541 - esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
2542 - echo '</td></tr>';
2543 - } else {
2544 - foreach ($grouped_prompts as $source_url => $group) {
2545 - $chunk_count = count($group);
2546 - $first_prompt = $group[0];
2547 - $display_index++;
2548 -
2549 - if ($chunk_count > 1) {
2550 - // Multiple chunks - show grouped row with expand button
2551 - $group_id = 'group-' . md5($source_url);
2552 - ?>
2553 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2554 - class="mxchat-chunk-group-header"
2555 - data-source="<?php echo esc_attr($data_source); ?>"
2556 - data-group-id="<?php echo esc_attr($group_id); ?>"
2557 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2558 - <td style="padding: 12px 16px; text-align: center;">
2559 - <input type="checkbox"
2560 - class="mxchat-entry-checkbox"
2561 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2562 - data-source="<?php echo esc_attr($data_source); ?>"
2563 - data-source-url="<?php echo esc_attr($source_url); ?>"
2564 - data-is-group="true"
2565 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2566 - </td>
2567 - <td style="padding: 12px 16px; font-size: 13px;">
2568 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2569 - </td>
2570 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2571 - <div class="mxchat-chunk-group-info">
2572 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2573 - <span class="dashicons dashicons-arrow-right-alt2"></span>
2574 - </button>
2575 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2576 - <span class="mxchat-chunk-preview">
2577 - <?php
2578 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
2579 - $content_preview = mb_substr($parent_content, 0, 100);
2580 - echo esc_html($content_preview . '...');
2581 - ?>
2582 - </span>
2583 - </div>
2584 - </td>
2585 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2586 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2587 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2588 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2589 - <?php esc_html_e('View Source', 'mxchat'); ?>
2590 - </a>
2591 - <?php else : ?>
2592 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2593 - <?php endif; ?>
2594 - </td>
2595 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2596 - <?php if ($data_source !== 'pinecone') : ?>
2597 - <button type="button"
2598 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2599 - data-source-url="<?php echo esc_attr($source_url); ?>"
2600 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2601 - data-data-source="<?php echo esc_attr($data_source); ?>"
2602 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2603 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2604 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2605 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2606 - </button>
2607 - <?php endif; ?>
2608 - <button type="button"
2609 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2610 - data-source-url="<?php echo esc_attr($source_url); ?>"
2611 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2612 - data-data-source="<?php echo esc_attr($data_source); ?>"
2613 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2614 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2615 - style="color: var(--mxch-error);"
2616 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2617 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2618 - </button>
2619 - </td>
2620 - </tr>
2621 - <?php
2622 - // Render hidden chunk rows
2623 - foreach ($group as $chunk_index => $chunk) {
2624 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2625 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2626 - $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
2627 - $content_preview = mb_strlen($content) > $preview_length
2628 - ? mb_substr($content, 0, $preview_length) . '...'
2629 - : $content;
2630 - ?>
2631 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2632 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2633 - data-source="<?php echo esc_attr($data_source); ?>"
2634 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2635 - <td style="padding: 12px 16px; text-align: center;">
2636 - <!-- Checkbox column placeholder for chunks (managed by group) -->
2637 - </td>
2638 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2639 - <!-- Hidden ID column for chunks -->
2640 - </td>
2641 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2642 - <div class="mxchat-accordion-wrapper">
2643 - <div class="mxchat-content-preview">
2644 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2645 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2646 - </span>
2647 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2648 - <?php if (mb_strlen($content) > $preview_length) : ?>
2649 - <button class="mxchat-expand-toggle" type="button">
2650 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2651 - </button>
2652 - <?php endif; ?>
2653 - </div>
2654 - <div class="mxchat-content-full" style="display: none;">
2655 - <div class="content-view">
2656 - <?php
2657 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2658 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2659 - echo wp_kses_post(wpautop($content));
2660 - echo '</div>';
2661 - } else {
2662 - echo wp_kses_post(wpautop($content));
2663 - }
2664 - ?>
2665 - </div>
2666 - </div>
2667 - </div>
2668 - </td>
2669 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2670 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2671 - </td>
2672 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2673 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2674 - </td>
2675 - </tr>
2676 - <?php
2677 - }
2678 - } else {
2679 - // Single entry - display normally with accordion
2680 - $prompt = $first_prompt;
2681 - $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
2682 - $content_preview = mb_strlen($content) > $preview_length
2683 - ? mb_substr($content, 0, $preview_length) . '...'
2684 - : $content;
2685 - ?>
2686 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2687 - data-source="<?php echo esc_attr($data_source); ?>"
2688 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2689 - <td style="padding: 12px 16px; text-align: center;">
2690 - <input type="checkbox"
2691 - class="mxchat-entry-checkbox"
2692 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2693 - data-source="<?php echo esc_attr($data_source); ?>"
2694 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
2695 - data-is-group="false"
2696 - data-chunk-count="1">
2697 - </td>
2698 - <td style="padding: 12px 16px; font-size: 13px;">
2699 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2700 - </td>
2701 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2702 - <div class="mxchat-accordion-wrapper">
2703 - <div class="mxchat-content-preview">
2704 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2705 - <?php if (mb_strlen($content) > $preview_length) : ?>
2706 - <button class="mxchat-expand-toggle" type="button">
2707 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2708 - </button>
2709 - <?php endif; ?>
2710 - </div>
2711 - <div class="mxchat-content-full" style="display: none;">
2712 - <div class="content-view">
2713 - <?php
2714 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2715 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2716 - echo wp_kses_post(wpautop($content));
2717 - echo '</div>';
2718 - } else {
2719 - echo wp_kses_post(wpautop($content));
2720 - }
2721 - ?>
2722 - </div>
2723 - </div>
2724 - </div>
2725 - </td>
2726 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2727 - <?php
2728 - $actual_source = $source_url;
2729 - if (strpos($source_url, '_ungrouped_') === 0) {
2730 - $actual_source = $prompt->source_url ?? '';
2731 - }
2732 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2733 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2734 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2735 - <?php esc_html_e('View', 'mxchat'); ?>
2736 - </a>
2737 - <?php else : ?>
2738 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2739 - <?php endif; ?>
2740 - </td>
2741 - <td style="padding: 12px 16px;">
2742 - <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);">
2743 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2744 - </button>
2745 - </td>
2746 - </tr>
2747 - <?php
2748 - }
2749 - }
2750 - }
2751 - $html = ob_get_clean();
2752 -
2753 - // Generate pagination HTML for Pinecone
2754 - $total_pages = ceil($total_records / $per_page);
2755 - $pagination_html = '';
2756 - if ($total_pages > 1) {
2757 - $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) . '">';
2758 -
2759 - // Previous button
2760 - if ($page > 1) {
2761 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2762 - }
2763 -
2764 - // Page numbers
2765 - $start_page = max(1, $page - 2);
2766 - $end_page = min($total_pages, $page + 2);
2767 -
2768 - if ($start_page > 1) {
2769 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2770 - if ($start_page > 2) {
2771 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2772 - }
2773 - }
2774 -
2775 - for ($i = $start_page; $i <= $end_page; $i++) {
2776 - if ($i == $page) {
2777 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2778 - } else {
2779 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2780 - }
2781 - }
2782 -
2783 - if ($end_page < $total_pages) {
2784 - if ($end_page < $total_pages - 1) {
2785 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2786 - }
2787 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2788 - }
2789 -
2790 - // Next button
2791 - if ($page < $total_pages) {
2792 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2793 - }
2794 -
2795 - $pagination_html .= '</div>';
2796 - }
2797 -
2798 - wp_send_json_success(array(
2799 - 'html' => $html,
2800 - 'pagination_html' => $pagination_html,
2801 - 'total_count' => $total_records,
2802 - 'total_pages' => $total_pages,
2803 - 'page' => $page,
2804 - 'per_page' => $per_page,
2805 - 'data_source' => 'pinecone'
2806 - ));
2807 -}
2808 -
2809 -/**
2810 - * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
2811 - * Returns paginated entries without requiring a full page reload
2812 - */
2813 -public function ajax_mxchat_paginate_entries() {
2814 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2815 -
2816 - if (!current_user_can('manage_options')) {
2817 - wp_send_json_error(array('message' => 'Unauthorized'));
2818 - return;
2819 - }
2820 -
2821 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2822 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2823 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2824 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2825 - $per_page = 25;
2826 -
2827 - // Check if Pinecone is enabled for this bot
2828 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2829 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2830 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2831 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2832 -
2833 - if ($use_pinecone && $has_pinecone_api) {
2834 - // Delegate to Pinecone pagination handler (pass search params)
2835 - $_POST['page'] = $page;
2836 - $_POST['search'] = $search_query;
2837 - $_POST['content_type'] = $content_type_filter;
2838 - $this->ajax_mxchat_refresh_pinecone_entries();
2839 - return;
2840 - }
2841 -
2842 - // WordPress DB pagination - MUST match initial page load logic exactly
2843 - global $wpdb;
2844 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2845 - $offset = ($page - 1) * $per_page;
2846 -
2847 - // Build WHERE clause for search and content type filtering
2848 - $where_clauses = array();
2849 - $where_values = array();
2850 -
2851 - if ($search_query) {
2852 - $where_clauses[] = "article_content LIKE %s";
2853 - $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
2854 - }
2855 -
2856 - if ($content_type_filter) {
2857 - switch ($content_type_filter) {
2858 - case 'manual':
2859 - $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
2860 - break;
2861 - case 'pdf':
2862 - $where_clauses[] = "source_url LIKE '%.pdf'";
2863 - break;
2864 - case 'url':
2865 - $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
2866 - break;
2867 - }
2868 - }
2869 -
2870 - $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
2871 -
2872 - // Count grouped entries with filters applied
2873 - if (!empty($where_values)) {
2874 - $count_args = array_merge($where_values, $where_values);
2875 - $count_query = $wpdb->prepare(
2876 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2877 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
2878 - ...$count_args
2879 - );
2880 - $total_records = $wpdb->get_var($count_query);
2881 - } else if (!empty($where_sql)) {
2882 - // Content type filter only (no search), no prepared values needed
2883 - $total_records = $wpdb->get_var(
2884 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2885 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
2886 - );
2887 - } else {
2888 - // No filters
2889 - $total_records = $wpdb->get_var(
2890 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2891 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2892 - );
2893 - }
2894 - $total_pages = ceil($total_records / $per_page);
2895 -
2896 - // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
2897 - if (!empty($where_values)) {
2898 - $query_args = array_merge($where_values, array($per_page, $offset));
2899 - $urls_query = $wpdb->prepare(
2900 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2901 - {$where_sql}
2902 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2903 - ...$query_args
2904 - );
2905 - } else if (!empty($where_sql)) {
2906 - $urls_query = $wpdb->prepare(
2907 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2908 - {$where_sql}
2909 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2910 - $per_page, $offset
2911 - );
2912 - } else {
2913 - $urls_query = $wpdb->prepare(
2914 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2915 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2916 - $per_page, $offset
2917 - );
2918 - }
2919 - $page_urls = $wpdb->get_results($urls_query);
2920 -
2921 - // Step 2: Build list of source_urls to fetch
2922 - $url_list = array();
2923 - $url_order_map = array();
2924 - $order_index = 0;
2925 - foreach ($page_urls as $url_row) {
2926 - $url = $url_row->source_url;
2927 - $url_list[] = $url;
2928 - $url_order_map[$url] = $order_index++;
2929 - }
2930 -
2931 - // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
2932 - $prompts = array();
2933 - if (!empty($url_list)) {
2934 - $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
2935 - if ($search_query) {
2936 - // Include search filter in the final fetch
2937 - $prompts_query = $wpdb->prepare(
2938 - "SELECT id, article_content, source_url, timestamp, role_restriction
2939 - FROM {$table_name}
2940 - WHERE source_url IN ($placeholders) AND article_content LIKE %s
2941 - ORDER BY timestamp DESC",
2942 - ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
2943 - );
2944 - } else {
2945 - $prompts_query = $wpdb->prepare(
2946 - "SELECT id, article_content, source_url, timestamp, role_restriction
2947 - FROM {$table_name}
2948 - WHERE source_url IN ($placeholders)
2949 - ORDER BY timestamp DESC",
2950 - $url_list
2951 - );
2952 - }
2953 - $prompts = $wpdb->get_results($prompts_query);
2954 - }
2955 -
2956 - // Group prompts by source_url for chunk display
2957 - $grouped_prompts = array();
2958 - foreach ($prompts as $prompt) {
2959 - $source_url = $prompt->source_url ?? '';
2960 -
2961 - // Parse chunk metadata using the proper chunker method (same as initial page load)
2962 - if (class_exists('MxChat_Chunker')) {
2963 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
2964 - $prompt->chunk_metadata = $chunk_meta['metadata'];
2965 - $prompt->display_content = $chunk_meta['text'];
2966 - } else {
2967 - $prompt->chunk_metadata = array();
2968 - $prompt->display_content = $prompt->article_content;
2969 - }
2970 -
2971 - if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
2972 - if (!isset($grouped_prompts[$source_url])) {
2973 - $grouped_prompts[$source_url] = array();
2974 - }
2975 - $grouped_prompts[$source_url][] = $prompt;
2976 - } else {
2977 - // Ungrouped entries
2978 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2979 - }
2980 - }
2981 -
2982 - // Sort groups by the original URL order (newest first)
2983 - uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
2984 - $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
2985 - $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
2986 - return $order_a - $order_b;
2987 - });
2988 -
2989 - // Sort each group internally by chunk_index
2990 - foreach ($grouped_prompts as $source_url => &$group) {
2991 - usort($group, function($a, $b) {
2992 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2993 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2994 - return $index_a - $index_b;
2995 - });
2996 - }
2997 - unset($group);
2998 -
2999 - // Build HTML for the table rows
3000 - ob_start();
3001 - $display_index = 0;
3002 - $current_page = $page;
3003 - $data_source = 'wordpress';
3004 - $current_bot_id = $bot_id;
3005 - $preview_length = 150;
3006 -
3007 - if (empty($grouped_prompts)) {
3008 - echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3009 - esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
3010 - echo '</td></tr>';
3011 - } else {
3012 - foreach ($grouped_prompts as $source_url => $group) {
3013 - $chunk_count = count($group);
3014 - $first_prompt = $group[0];
3015 - $display_index++;
3016 -
3017 - if ($chunk_count > 1) {
3018 - // Multiple chunks - show grouped row with expand button
3019 - $group_id = 'group-' . md5($source_url);
3020 - ?>
3021 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3022 - class="mxchat-chunk-group-header"
3023 - data-source="<?php echo esc_attr($data_source); ?>"
3024 - data-group-id="<?php echo esc_attr($group_id); ?>"
3025 - style="border-bottom: 1px solid var(--mxch-card-border);">
3026 - <td style="padding: 12px 16px; text-align: center;">
3027 - <input type="checkbox"
3028 - class="mxchat-entry-checkbox"
3029 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3030 - data-source="<?php echo esc_attr($data_source); ?>"
3031 - data-source-url="<?php echo esc_attr($source_url); ?>"
3032 - data-is-group="true"
3033 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3034 - </td>
3035 - <td style="padding: 12px 16px; font-size: 13px;">
3036 - <?php echo esc_html($first_prompt->id); ?>
3037 - </td>
3038 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3039 - <div class="mxchat-chunk-group-info">
3040 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3041 - <span class="dashicons dashicons-arrow-right-alt2"></span>
3042 - </button>
3043 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3044 - <span class="mxchat-chunk-preview">
3045 - <?php
3046 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
3047 - $content_preview = mb_substr($parent_content, 0, 100);
3048 - echo esc_html($content_preview . '...');
3049 - ?>
3050 - </span>
3051 - </div>
3052 - </td>
3053 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3054 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3055 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3056 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3057 - <?php esc_html_e('View Source', 'mxchat'); ?>
3058 - </a>
3059 - <?php else : ?>
3060 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3061 - <?php endif; ?>
3062 - </td>
3063 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3064 - <?php if ($data_source !== 'pinecone') : ?>
3065 - <button type="button"
3066 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3067 - data-source-url="<?php echo esc_attr($source_url); ?>"
3068 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3069 - data-data-source="<?php echo esc_attr($data_source); ?>"
3070 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3071 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3072 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3073 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3074 - </button>
3075 - <?php endif; ?>
3076 - <button type="button"
3077 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3078 - data-source-url="<?php echo esc_attr($source_url); ?>"
3079 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3080 - data-data-source="<?php echo esc_attr($data_source); ?>"
3081 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3082 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3083 - style="color: var(--mxch-error);"
3084 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3085 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3086 - </button>
3087 - </td>
3088 - </tr>
3089 - <?php
3090 - // Render hidden chunk rows
3091 - foreach ($group as $chunk_index => $chunk) {
3092 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3093 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3094 - $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
3095 - $content_preview = mb_strlen($content) > $preview_length
3096 - ? mb_substr($content, 0, $preview_length) . '...'
3097 - : $content;
3098 - ?>
3099 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3100 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3101 - data-source="<?php echo esc_attr($data_source); ?>"
3102 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3103 - <td style="padding: 12px 16px; text-align: center;">
3104 - <!-- Checkbox column placeholder for chunks (managed by group) -->
3105 - </td>
3106 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3107 - <!-- Hidden ID column for chunks -->
3108 - </td>
3109 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3110 - <div class="mxchat-accordion-wrapper">
3111 - <div class="mxchat-content-preview">
3112 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3113 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3114 - </span>
3115 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3116 - <?php if (mb_strlen($content) > $preview_length) : ?>
3117 - <button class="mxchat-expand-toggle" type="button">
3118 - <span class="dashicons dashicons-arrow-down-alt2"></span>
3119 - </button>
3120 - <?php endif; ?>
3121 - </div>
3122 - <div class="mxchat-content-full" style="display: none;">
3123 - <div class="content-view">
3124 - <?php
3125 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3126 - echo '<div dir="rtl" lang="he" class="rtl-content">';
3127 - echo wp_kses_post(wpautop($content));
3128 - echo '</div>';
3129 - } else {
3130 - echo wp_kses_post(wpautop($content));
3131 - }
3132 - ?>
3133 - </div>
3134 - </div>
3135 - </div>
3136 - </td>
3137 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3138 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3139 - </td>
3140 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3141 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3142 - </td>
3143 - </tr>
3144 - <?php
3145 - }
3146 - } else {
3147 - // Single entry - display normally with accordion
3148 - $prompt = $first_prompt;
3149 - $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
3150 - $content_preview = mb_strlen($content) > $preview_length
3151 - ? mb_substr($content, 0, $preview_length) . '...'
3152 - : $content;
3153 - ?>
3154 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3155 - data-source="<?php echo esc_attr($data_source); ?>"
3156 - style="border-bottom: 1px solid var(--mxch-card-border);">
3157 - <td style="padding: 12px 16px; text-align: center;">
3158 - <input type="checkbox"
3159 - class="mxchat-entry-checkbox"
3160 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3161 - data-source="<?php echo esc_attr($data_source); ?>"
3162 - data-source-url="<?php echo esc_attr($source_url); ?>"
3163 - data-is-group="false">
3164 - </td>
3165 - <td style="padding: 12px 16px; font-size: 13px;">
3166 - <?php echo esc_html($prompt->id); ?>
3167 - </td>
3168 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3169 - <div class="mxchat-accordion-wrapper">
3170 - <div class="mxchat-content-preview">
3171 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3172 - <?php if (mb_strlen($content) > $preview_length) : ?>
3173 - <button class="mxchat-expand-toggle" type="button">
3174 - <span class="dashicons dashicons-arrow-down-alt2"></span>
3175 - </button>
3176 - <?php endif; ?>
3177 - </div>
3178 - <div class="mxchat-content-full" style="display: none;">
3179 - <div class="content-view">
3180 - <?php
3181 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3182 - echo '<div dir="rtl" lang="he" class="rtl-content">';
3183 - echo wp_kses_post(wpautop($content));
3184 - echo '</div>';
3185 - } else {
3186 - echo wp_kses_post(wpautop($content));
3187 - }
3188 - ?>
3189 - </div>
3190 - </div>
3191 - </div>
3192 - </td>
3193 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3194 - <?php
3195 - $actual_source = $source_url;
3196 - if (strpos($source_url, '_ungrouped_') === 0) {
3197 - $actual_source = $prompt->source_url ?? '';
3198 - }
3199 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3200 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3201 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3202 - <?php esc_html_e('View', 'mxchat'); ?>
3203 - </a>
3204 - <?php else : ?>
3205 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3206 - <?php endif; ?>
3207 - </td>
3208 - <td style="padding: 12px 16px; white-space: nowrap;">
3209 - <button type="button"
3210 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3211 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3212 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3213 - data-data-source="<?php echo esc_attr($data_source); ?>"
3214 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3215 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3216 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3217 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3218 - </button>
3219 - <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);">
3220 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3221 - </button>
3222 - </td>
3223 - </tr>
3224 - <?php
3225 - }
3226 - }
3227 - }
3228 - $html = ob_get_clean();
3229 -
3230 - // Generate pagination HTML (include search/filter data for subsequent pages)
3231 - $pagination_html = '';
3232 - if ($total_pages > 1) {
3233 - $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) . '">';
3234 -
3235 - // Previous button
3236 - if ($page > 1) {
3237 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3238 - }
3239 -
3240 - // Page numbers
3241 - $start_page = max(1, $page - 2);
3242 - $end_page = min($total_pages, $page + 2);
3243 -
3244 - if ($start_page > 1) {
3245 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3246 - if ($start_page > 2) {
3247 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3248 - }
3249 - }
3250 -
3251 - for ($i = $start_page; $i <= $end_page; $i++) {
3252 - if ($i == $page) {
3253 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3254 - } else {
3255 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3256 - }
3257 - }
3258 -
3259 - if ($end_page < $total_pages) {
3260 - if ($end_page < $total_pages - 1) {
3261 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3262 - }
3263 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3264 - }
3265 -
3266 - // Next button
3267 - if ($page < $total_pages) {
3268 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3269 - }
3270 -
3271 - $pagination_html .= '</div>';
3272 - }
3273 -
3274 - wp_send_json_success(array(
3275 - 'html' => $html,
3276 - 'pagination_html' => $pagination_html,
3277 - 'total_count' => $total_records,
3278 - 'total_pages' => $total_pages,
3279 - 'page' => $page,
3280 - 'per_page' => $per_page,
3281 - 'data_source' => 'wordpress'
3282 - ));
3283 -}
3284 -
3285 -/**
3286 - * AJAX handler to detect available sitemaps on the site
3287 - * Optimized for speed - only checks primary sitemap indexes first
3288 - */
3289 -public function ajax_mxchat_detect_sitemaps() {
3290 - check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
3291 -
3292 - if (!current_user_can('manage_options')) {
3293 - wp_send_json_error(array('message' => 'Unauthorized'));
3294 - return;
3295 - }
3296 -
3297 - $site_url = get_site_url();
3298 - $sitemaps = array();
3299 - $found_index = false;
3300 -
3301 - // Only check the main sitemap index files first (much faster)
3302 - // These are the primary entry points that contain sub-sitemaps
3303 - $primary_indexes = array(
3304 - 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
3305 - 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
3306 - 'sitemap.xml' => 'Standard', // Generic/AIOSEO
3307 - );
3308 -
3309 - foreach ($primary_indexes as $path => $source) {
3310 - $url = trailingslashit($site_url) . $path;
3311 -
3312 - $response = wp_remote_head($url, array(
3313 - 'timeout' => 10,
3314 - 'sslverify' => false,
3315 - 'redirection' => 1,
3316 - 'user-agent' => mxchat_ingest_user_agent(),
3317 - ));
3318 -
3319 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3320 - // Found a sitemap index - parse it to get sub-sitemaps
3321 - $sub_sitemaps = $this->parse_sitemap_index($url);
3322 - if (!empty($sub_sitemaps)) {
3323 - $sitemaps[] = array(
3324 - 'url' => $url,
3325 - 'type' => 'index',
3326 - 'source' => $source,
3327 - 'sub_sitemaps' => $sub_sitemaps
3328 - );
3329 - $found_index = true;
3330 - // Found a valid index, no need to check others
3331 - break;
3332 - }
3333 - }
3334 - }
3335 -
3336 - // If no sitemap index found, check for standalone sitemaps
3337 - if (!$found_index) {
3338 - $standalone_sitemaps = array(
3339 - 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3340 - 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3341 - );
3342 -
3343 - foreach ($standalone_sitemaps as $path => $info) {
3344 - $url = trailingslashit($site_url) . $path;
3345 -
3346 - $response = wp_remote_head($url, array(
3347 - 'timeout' => 2,
3348 - 'sslverify' => false
3349 - ));
3350 -
3351 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3352 - $sitemaps[] = array(
3353 - 'url' => $url,
3354 - 'type' => $info['type'],
3355 - 'source' => $info['source'],
3356 - 'url_count' => 0 // Skip URL count for speed
3357 - );
3358 - }
3359 - }
3360 - }
3361 -
3362 - wp_send_json_success(array(
3363 - 'sitemaps' => $sitemaps,
3364 - 'site_url' => $site_url
3365 - ));
3366 -}
3367 -
3368 -/**
3369 - * Parse a sitemap index to get sub-sitemaps
3370 - * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
3371 - */
3372 -private function parse_sitemap_index($url) {
3373 - $sub_sitemaps = array();
3374 -
3375 - $response = wp_remote_get($url, array(
3376 - 'timeout' => 30,
3377 - 'sslverify' => false,
3378 - 'user-agent' => mxchat_ingest_user_agent(),
3379 - 'headers' => array(
3380 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3381 - ),
3382 - ));
3383 -
3384 - if (is_wp_error($response)) {
3385 - return $sub_sitemaps;
3386 - }
3387 -
3388 - $body = wp_remote_retrieve_body($response);
3389 - if (empty($body)) {
3390 - return $sub_sitemaps;
3391 - }
3392 -
3393 - // Suppress XML errors
3394 - libxml_use_internal_errors(true);
3395 - $xml = simplexml_load_string($body);
3396 - libxml_clear_errors();
3397 -
3398 - if ($xml === false) {
3399 - return $sub_sitemaps;
3400 - }
3401 -
3402 - // Check if it's a sitemap index (contains <sitemap> elements)
3403 - if (isset($xml->sitemap)) {
3404 - foreach ($xml->sitemap as $sitemap) {
3405 - $loc = (string) $sitemap->loc;
3406 - if (!empty($loc)) {
3407 - // Try to determine the type from the URL
3408 - $type = 'content';
3409 - if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
3410 - $type = 'taxonomy';
3411 - } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
3412 - $type = 'author';
3413 - }
3414 -
3415 - // Skip URL count - too slow to fetch for each sitemap
3416 - $sub_sitemaps[] = array(
3417 - 'url' => $loc,
3418 - 'type' => $type,
3419 - 'url_count' => 0, // Don't fetch - takes too long
3420 - 'name' => basename(parse_url($loc, PHP_URL_PATH))
3421 - );
3422 - }
3423 - }
3424 - }
3425 -
3426 - return $sub_sitemaps;
3427 -}
3428 -
3429 -/**
3430 - * Get URL count from a sitemap
3431 - */
3432 -private function get_sitemap_url_count($url) {
3433 - $response = wp_remote_get($url, array(
3434 - 'timeout' => 30,
3435 - 'sslverify' => false,
3436 - 'user-agent' => mxchat_ingest_user_agent(),
3437 - 'headers' => array(
3438 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3439 - ),
3440 - ));
3441 -
3442 - if (is_wp_error($response)) {
3443 - return 0;
3444 - }
3445 -
3446 - $body = wp_remote_retrieve_body($response);
3447 - if (empty($body)) {
3448 - return 0;
3449 - }
3450 -
3451 - // Count <url> or <loc> elements
3452 - $count = preg_match_all('/<url>/i', $body, $matches);
3453 - return $count ?: 0;
3454 -}
3455 -
3456 -/**
3457 - * Get sitemaps declared in robots.txt
3458 - */
3459 -private function get_sitemaps_from_robots($site_url) {
3460 - $sitemaps = array();
3461 - $robots_url = trailingslashit($site_url) . 'robots.txt';
3462 -
3463 - $response = wp_remote_get($robots_url, array(
3464 - 'timeout' => 15,
3465 - 'sslverify' => false,
3466 - 'user-agent' => mxchat_ingest_user_agent(),
3467 - ));
3468 -
3469 - if (is_wp_error($response)) {
3470 - return $sitemaps;
3471 - }
3472 -
3473 - $body = wp_remote_retrieve_body($response);
3474 - if (empty($body)) {
3475 - return $sitemaps;
3476 - }
3477 -
3478 - // Find Sitemap: declarations
3479 - if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
3480 - foreach ($matches[1] as $sitemap_url) {
3481 - $sitemap_url = trim($sitemap_url);
3482 - if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
3483 - $sitemaps[] = $sitemap_url;
3484 - }
3485 - }
3486 - }
3487 -
3488 - return $sitemaps;
3489 -}
3490 -
3491 -public function mxchat_stop_processing() {
3492 - // Verify permissions
3493 - if (!current_user_can('manage_options')) {
3494 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
3495 - }
3496 -
3497 - // Verify nonce
3498 - check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
3499 -
3500 - global $wpdb;
3501 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
3502 -
3503 - // Get active queue IDs
3504 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
3505 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
3506 -
3507 - // Delete all pending items from active queues
3508 - if ($sitemap_queue_id) {
3509 - $wpdb->delete(
3510 - $table_name,
3511 - array(
3512 - 'queue_id' => $sitemap_queue_id,
3513 - 'status' => 'pending'
3514 - ),
3515 - array('%s', '%s')
3516 - );
3517 -
3518 - delete_transient('mxchat_active_queue_sitemap');
3519 - delete_transient('mxchat_last_sitemap_url');
3520 - }
3521 -
3522 - if ($pdf_queue_id) {
3523 - // Get PDF path before deleting
3524 - $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
3525 -
3526 - $wpdb->delete(
3527 - $table_name,
3528 - array(
3529 - 'queue_id' => $pdf_queue_id,
3530 - 'status' => 'pending'
3531 - ),
3532 - array('%s', '%s')
3533 - );
3534 -
3535 - // Delete PDF file
3536 - if ($pdf_path && file_exists($pdf_path)) {
3537 - wp_delete_file($pdf_path);
3538 - }
3539 -
3540 - delete_transient('mxchat_active_queue_pdf');
3541 - delete_transient('mxchat_last_pdf_url');
3542 - }
3543 -
3544 - // Redirect back with a success message
3545 - set_transient('mxchat_admin_notice_success',
3546 - esc_html__('Processing has been stopped successfully.', 'mxchat'),
3547 - 30
3548 - );
3549 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
3550 - exit;
3551 -}
3552 -
3553 -/**
3554 - * Get content list for processing
3555 - */
3556 -public function ajax_mxchat_get_content_list() {
3557 - // Verify the nonce
3558 - check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
3559 -
3560 - if (!current_user_can('manage_options')) {
3561 - wp_send_json_error(__('Unauthorized access', 'mxchat'));
3562 - }
3563 -
3564 - $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
3565 - $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
3566 - $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
3567 - $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
3568 - $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
3569 - $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
3570 -
3571 - // Build query args
3572 - $args = array(
3573 - 'posts_per_page' => $per_page,
3574 - 'paged' => $page,
3575 - 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
3576 - 'orderby' => 'date',
3577 - 'order' => 'DESC',
3578 - );
3579 -
3580 - // Handle post types - IMPROVED VERSION
3581 - if ($post_type !== 'all') {
3582 - $args['post_type'] = $post_type;
3583 - } else {
3584 - // Get all available post types that might contain content
3585 - $all_post_types = array();
3586 -
3587 - // First get all public post types
3588 - $public_types = get_post_types(array('public' => true), 'names');
3589 - $all_post_types = array_merge($all_post_types, $public_types);
3590 -
3591 - // Add common forum/community post types
3592 - $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
3593 - foreach ($forum_types as $forum_type) {
3594 - if (post_type_exists($forum_type)) {
3595 - $all_post_types[] = $forum_type;
3596 - }
3597 - }
3598 -
3599 - // Add other commonly used post types
3600 - $common_types = array('product', 'job_listing', 'event', 'portfolio');
3601 - foreach ($common_types as $common_type) {
3602 - if (post_type_exists($common_type)) {
3603 - $all_post_types[] = $common_type;
3604 - }
3605 - }
3606 -
3607 - // Remove duplicates and ensure we have at least some post types
3608 - $all_post_types = array_unique($all_post_types);
3609 -
3610 - if (empty($all_post_types)) {
3611 - // Fallback to basic post types
3612 - $all_post_types = array('post', 'page');
3613 - }
3614 -
3615 - $args['post_type'] = $all_post_types;
3616 -
3617 - // Debug logging to see what post types are being queried
3618 - //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
3619 - }
3620 -
3621 - if (!empty($search)) {
3622 - $args['s'] = $search;
3623 - }
3624 -
3625 - // Get processed data from storage
3626 - $processed_data = array();
3627 -
3628 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3629 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3630 -
3631 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3632 - // Get fresh data from Pinecone - no caching
3633 - $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
3634 - } else {
3635 - // WordPress DB checking with better URL matching for all post types
3636 - global $wpdb;
3637 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3638 - $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
3639 -
3640 - // Group items by source_url to count chunks
3641 - $url_chunk_counts = array();
3642 - $url_latest_timestamp = array();
3643 - $url_first_id = array();
3644 -
3645 - if (!empty($processed_items)) {
3646 - foreach ($processed_items as $item) {
3647 - $url = $item->source_url;
3648 - if (empty($url)) continue;
3649 -
3650 - // Count chunks per URL
3651 - if (!isset($url_chunk_counts[$url])) {
3652 - $url_chunk_counts[$url] = 0;
3653 - $url_latest_timestamp[$url] = $item->timestamp;
3654 - $url_first_id[$url] = $item->id;
3655 - }
3656 - $url_chunk_counts[$url]++;
3657 -
3658 - // Track latest timestamp
3659 - if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
3660 - $url_latest_timestamp[$url] = $item->timestamp;
3661 - }
3662 - }
3663 -
3664 - // Now build processed_data with chunk counts
3665 - foreach ($url_chunk_counts as $url => $chunk_count) {
3666 - $post_id = $this->mxchat_url_to_post_id_improved($url);
3667 -
3668 - if ($post_id) {
3669 - $processed_data[$post_id] = array(
3670 - 'db_id' => $url_first_id[$url],
3671 - 'timestamp' => $url_latest_timestamp[$url],
3672 - 'url' => $url,
3673 - 'source' => 'wordpress',
3674 - 'chunk_count' => $chunk_count
3675 - );
3676 - }
3677 - }
3678 - }
3679 - }
3680 -
3681 - // Get processed IDs as a simple array for in_array checks
3682 - $processed_ids = array_keys($processed_data);
3683 -
3684 - // Handle processed/unprocessed filter
3685 - if ($processed_filter === 'processed' && !empty($processed_ids)) {
3686 - $args['post__in'] = $processed_ids;
3687 - } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
3688 - $args['post__not_in'] = $processed_ids;
3689 - }
3690 -
3691 - // Run the query
3692 - $query = new WP_Query($args);
3693 - $content_items = array();
3694 -
3695 - if ($query->have_posts()) {
3696 - while ($query->have_posts()) {
3697 - $query->the_post();
3698 - $id = get_the_ID();
3699 - $post_date = get_the_date();
3700 - $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
3701 - $word_count = str_word_count(strip_tags(get_the_content()));
3702 -
3703 - $is_processed = in_array($id, $processed_ids);
3704 - $processed_date = '';
3705 - $db_record_id = 0;
3706 - $data_source = 'none';
3707 -
3708 - if ($is_processed && isset($processed_data[$id])) {
3709 - $item_data = $processed_data[$id];
3710 - $data_source = $item_data['source'];
3711 -
3712 - if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
3713 - // WordPress DB format
3714 - $timestamp = strtotime($item_data['timestamp']);
3715 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3716 - $db_record_id = $item_data['db_id'];
3717 - } elseif ($data_source === 'pinecone') {
3718 - // Pinecone format
3719 - $processed_date = $item_data['processed_date'];
3720 - $db_record_id = $item_data['db_id'];
3721 - }
3722 - }
3723 -
3724 - // Get chunk count for this item
3725 - $chunk_count = 0;
3726 - if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
3727 - $chunk_count = intval($processed_data[$id]['chunk_count']);
3728 - }
3729 -
3730 - $content_items[] = array(
3731 - 'id' => $id,
3732 - 'title' => get_the_title(),
3733 - 'permalink' => get_permalink(),
3734 - 'date' => $post_date,
3735 - 'type' => get_post_type(),
3736 - 'status' => get_post_status(),
3737 - 'excerpt' => $excerpt,
3738 - 'word_count' => $word_count,
3739 - 'already_processed' => $is_processed,
3740 - 'processed_date' => $processed_date,
3741 - 'db_record_id' => $db_record_id,
3742 - 'data_source' => $data_source,
3743 - 'chunk_count' => $chunk_count
3744 - );
3745 - }
3746 - wp_reset_postdata();
3747 - }
3748 -
3749 - $response = array(
3750 - 'items' => $content_items,
3751 - 'total' => $query->found_posts,
3752 - 'total_pages' => $query->max_num_pages,
3753 - 'current_page' => $page,
3754 - 'processed_count' => count($processed_ids)
3755 - );
3756 -
3757 - wp_send_json_success($response);
3758 - exit;
3759 -}
3760 -
3761 -
3762 -/**
3763 - * This function handles various WooCommerce URL formats and permalink structures
3764 - */
3765 -private function mxchat_url_to_post_id_improved($url) {
3766 - // First try the standard WordPress function
3767 - $post_id = url_to_postid($url);
3768 -
3769 - if ($post_id > 0) {
3770 - return $post_id;
3771 - }
3772 -
3773 - // If that fails, try more aggressive URL matching
3774 - // Remove trailing slashes and query parameters for better matching
3775 - $clean_url = rtrim($url, '/');
3776 - $clean_url = strtok($clean_url, '?'); // Remove query parameters
3777 -
3778 - // Try again with cleaned URL
3779 - $post_id = url_to_postid($clean_url);
3780 - if ($post_id > 0) {
3781 - return $post_id;
3782 - }
3783 -
3784 - // For bbPress forum topics, try extracting slug from URL
3785 - if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
3786 - // Handle bbPress URLs: /forums/topic/topic-name/
3787 - if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
3788 - $topic_slug = $matches[1];
3789 -
3790 - // Look up topic by slug
3791 - $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
3792 - if ($topic) {
3793 - return $topic->ID;
3794 - }
3795 -
3796 - // Alternative method: query by post_name
3797 - global $wpdb;
3798 - $post_id = $wpdb->get_var($wpdb->prepare(
3799 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3800 - $topic_slug
3801 - ));
3802 -
3803 - if ($post_id) {
3804 - return intval($post_id);
3805 - }
3806 - }
3807 -
3808 - // Handle simpler topic URLs: /topic/topic-name/
3809 - if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
3810 - $topic_slug = $matches[1];
3811 -
3812 - global $wpdb;
3813 - $post_id = $wpdb->get_var($wpdb->prepare(
3814 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3815 - $topic_slug
3816 - ));
3817 -
3818 - if ($post_id) {
3819 - return intval($post_id);
3820 - }
3821 - }
3822 - }
3823 -
3824 - // For WooCommerce products
3825 - if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
3826 - // Extract product slug from various URL formats
3827 - $product_slug = '';
3828 -
3829 - // Handle pretty permalinks: /product/product-name/
3830 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
3831 - $product_slug = $matches[1];
3832 - }
3833 - // Handle query parameters: ?product=product-name
3834 - elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
3835 - $product_slug = $matches[1];
3836 - }
3837 -
3838 - if (!empty($product_slug)) {
3839 - // Look up product by slug
3840 - $product = get_page_by_path($product_slug, OBJECT, 'product');
3841 - if ($product) {
3842 - return $product->ID;
3843 - }
3844 -
3845 - // Alternative method: query by post_name
3846 - global $wpdb;
3847 - $post_id = $wpdb->get_var($wpdb->prepare(
3848 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
3849 - $product_slug
3850 - ));
3851 -
3852 - if ($post_id) {
3853 - return intval($post_id);
3854 - }
3855 - }
3856 - }
3857 -
3858 - // Generic approach: try to extract slug and match against all post types
3859 - $parsed_url = wp_parse_url($clean_url);
3860 - $path = $parsed_url['path'] ?? '';
3861 -
3862 - if (!empty($path)) {
3863 - // Get the last part of the path as potential slug
3864 - $path_parts = array_filter(explode('/', trim($path, '/')));
3865 - $potential_slug = end($path_parts);
3866 -
3867 - if (!empty($potential_slug)) {
3868 - global $wpdb;
3869 -
3870 - // Try to find any post with this slug
3871 - $post_id = $wpdb->get_var($wpdb->prepare(
3872 - "SELECT ID FROM {$wpdb->posts}
3873 - WHERE post_name = %s
3874 - AND post_status IN ('publish', 'closed', 'private')
3875 - AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
3876 - ORDER BY CASE
3877 - WHEN post_type = 'post' THEN 1
3878 - WHEN post_type = 'page' THEN 2
3879 - WHEN post_type = 'topic' THEN 3
3880 - WHEN post_type = 'product' THEN 4
3881 - ELSE 5
3882 - END
3883 - LIMIT 1",
3884 - $potential_slug
3885 - ));
3886 -
3887 - if ($post_id) {
3888 - return intval($post_id);
3889 - }
3890 - }
3891 - }
3892 -
3893 - // ADDITIONAL: Try direct database lookup by URL variations
3894 - global $wpdb;
3895 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3896 -
3897 - // Try variations of the URL (with/without trailing slash, http/https)
3898 - $url_variations = array(
3899 - $url,
3900 - rtrim($url, '/'),
3901 - $url . '/',
3902 - str_replace('http://', 'https://', $url),
3903 - str_replace('https://', 'http://', $url),
3904 - str_replace('http://', 'https://', rtrim($url, '/')),
3905 - str_replace('https://', 'http://', rtrim($url, '/'))
3906 - );
3907 -
3908 - // Remove duplicates
3909 - $url_variations = array_unique($url_variations);
3910 -
3911 - foreach ($url_variations as $variation) {
3912 - $existing_record = $wpdb->get_row($wpdb->prepare(
3913 - "SELECT id, source_url FROM $table_name WHERE source_url = %s",
3914 - $variation
3915 - ));
3916 -
3917 - if ($existing_record) {
3918 - // Try to get post ID from this stored URL
3919 - $stored_post_id = url_to_postid($existing_record->source_url);
3920 - if ($stored_post_id > 0) {
3921 - return $stored_post_id;
3922 - }
3923 - }
3924 - }
3925 -
3926 - return 0; // No match found
3927 -}
3928 -/**
3929 - * Process selected content via AJAX
3930 - */
3931 -public function ajax_mxchat_process_selected_content() {
3932 - // Basic request validation
3933 - if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
3934 - wp_send_json_error('Invalid nonce');
3935 - exit;
3936 - }
3937 -
3938 - if (!current_user_can('manage_options')) {
3939 - wp_send_json_error('Unauthorized access');
3940 - exit;
3941 - }
3942 -
3943 - // Get post IDs - safely parse the array
3944 - $post_ids = array();
3945 - if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
3946 - foreach ($_POST['post_ids'] as $id) {
3947 - $post_ids[] = absint($id);
3948 - }
3949 - }
3950 -
3951 - if (empty($post_ids)) {
3952 - wp_send_json_error('No content selected');
3953 - exit;
3954 - }
3955 -
3956 - // Get bot_id from request
3957 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3958 -
3959 - // ACF→PDF extraction is opt-in per import batch. Persist the last-used value so users
3960 - // don't re-check on every batch; the default is OFF for installs that haven't set it.
3961 - $extract_acf_pdfs = !empty($_POST['extract_acf_pdfs']) && $_POST['extract_acf_pdfs'] !== 'false';
3962 - $mxchat_options = get_option('mxchat_options', array());
3963 - if (!is_array($mxchat_options)) {
3964 - $mxchat_options = array();
3965 - }
3966 - $prior_default = !empty($mxchat_options['acf_pdf_extract_default']);
3967 - if ($prior_default !== $extract_acf_pdfs) {
3968 - $mxchat_options['acf_pdf_extract_default'] = $extract_acf_pdfs ? 1 : 0;
3969 - update_option('mxchat_options', $mxchat_options);
3970 - }
3971 -
3972 - // Process only ONE post at a time to avoid request size issues
3973 - $post_id = reset($post_ids);
3974 - $post = get_post($post_id);
3975 -
3976 - if (!$post) {
3977 - wp_send_json_error('Post not found');
3978 - exit;
3979 - }
3980 -
3981 - // Allow developers to modify post data before processing into knowledge base
3982 - $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
3983 -
3984 - // Get content including title, short description (for WooCommerce), and main content
3985 - $content = $post->post_title . "\n\n";
3986 -
3987 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
3988 - if (!empty($post->post_excerpt)) {
3989 - // Remove shortcode tags but preserve content inside them
3990 - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
3991 - $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
3992 - }
3993 -
3994 - // Add main content - remove shortcode tags but preserve content inside them
3995 - $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
3996 - $content .= wp_strip_all_tags($clean_content);
3997 -
3998 - // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
3999 - if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
4000 - $product = wc_get_product($post_id);
4001 -
4002 - if ($product) {
4003 - // Get pricing information
4004 - $regular_price = $product->get_regular_price();
4005 - $sale_price = $product->get_sale_price();
4006 - $price = $product->get_price();
4007 - $sku = $product->get_sku();
4008 -
4009 - // Get currency symbol
4010 - $currency_symbol = get_woocommerce_currency_symbol();
4011 -
4012 - // Add pricing information
4013 - $content .= "\n";
4014 - if (!empty($regular_price)) {
4015 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
4016 - } elseif (!empty($price)) {
4017 - $content .= "Price: " . $currency_symbol . $price . "\n";
4018 - }
4019 -
4020 - if (!empty($sale_price) && $sale_price !== $regular_price) {
4021 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
4022 - }
4023 -
4024 - // Handle variable products - show price range
4025 - if ($product->is_type('variable')) {
4026 - $min_price = $product->get_variation_price('min');
4027 - $max_price = $product->get_variation_price('max');
4028 - if ($min_price !== $max_price) {
4029 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
4030 - }
4031 - }
4032 -
4033 - if (!empty($sku)) {
4034 - $content .= "SKU: " . $sku . "\n";
4035 - }
4036 -
4037 - // Get product categories
4038 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
4039 - if (!empty($categories) && !is_wp_error($categories)) {
4040 - $content .= "Categories: " . implode(', ', $categories) . "\n";
4041 - }
4042 - }
4043 -
4044 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
4045 - $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
4046 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
4047 - foreach ($custom_tabs as $tab) {
4048 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
4049 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
4050 -
4051 - if (!empty($tab_title) && !empty($tab_content)) {
4052 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
4053 - }
4054 - }
4055 - }
4056 -
4057 - // Also check for reusable/saved tabs applied to this product
4058 - $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
4059 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
4060 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
4061 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
4062 - foreach ($applied_saved_tabs as $saved_tab_id) {
4063 - if (isset($saved_tabs[$saved_tab_id])) {
4064 - $tab = $saved_tabs[$saved_tab_id];
4065 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
4066 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
4067 -
4068 - if (!empty($tab_title) && !empty($tab_content)) {
4069 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
4070 - }
4071 - }
4072 - }
4073 - }
4074 - }
4075 - }
4076 -
4077 - // ADD ACF FIELDS SUPPORT
4078 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4079 - $pdf_extracted_count = 0;
4080 - if (!empty($acf_fields)) {
4081 - $acf_content_parts = array();
4082 - $pdf_attachment_ids = array();
4083 -
4084 - foreach ($acf_fields as $field_name => $field_value) {
4085 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
4086 -
4087 - if (!empty($formatted_value)) {
4088 - $field_label = ucwords(str_replace('_', ' ', $field_name));
4089 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
4090 - }
4091 -
4092 - // Walk this field's value tree for any PDF attachment references and queue them for extraction.
4093 - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
4094 - // still lands in the KB but the heavier PDF parsing is skipped.
4095 - if ($extract_acf_pdfs) {
4096 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
4097 - }
4098 - }
4099 -
4100 - if (!empty($acf_content_parts)) {
4101 - $content .= "\n\n" . implode("\n", $acf_content_parts);
4102 - }
4103 -
4104 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
4105 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
4106 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
4107 - $pdf_sections = array();
4108 - foreach ($pdf_attachment_ids as $att_id) {
4109 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
4110 - if (!empty($pdf_text)) {
4111 - $pdf_title = get_the_title($att_id);
4112 - $pdf_url = wp_get_attachment_url($att_id);
4113 - $header = 'PDF Attachment';
4114 - if (!empty($pdf_title)) {
4115 - $header .= ': ' . $pdf_title;
4116 - }
4117 - if (!empty($pdf_url)) {
4118 - $header .= ' (' . $pdf_url . ')';
4119 - }
4120 - $pdf_sections[] = $header . "\n" . $pdf_text;
4121 - $pdf_extracted_count++;
4122 - }
4123 - }
4124 - if (!empty($pdf_sections)) {
4125 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
4126 - }
4127 - }
4128 - }
4129 -
4130 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
4131 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
4132 - if (!empty($custom_meta)) {
4133 - $meta_content_parts = array();
4134 -
4135 - foreach ($custom_meta as $meta_key => $meta_value) {
4136 - // Convert meta key to readable label
4137 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
4138 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
4139 - }
4140 -
4141 - if (!empty($meta_content_parts)) {
4142 - $content .= "\n\n" . implode("\n", $meta_content_parts);
4143 - }
4144 - }
4145 -
4146 - // Debug logging for WordPress Import content
4147 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
4148 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
4149 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
4150 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
4151 -
4152 - // Note: Removed 10,000 char limit - chunking now handles large content properly
4153 -
4154 - // Get bot-specific API key
4155 - $bot_options = $this->get_bot_options($bot_id);
4156 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4157 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4158 -
4159 - if (strpos($selected_model, 'voyage') === 0) {
4160 - $api_key = $options['voyage_api_key'] ?? '';
4161 - $provider_name = 'Voyage AI';
4162 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4163 - $api_key = $options['gemini_api_key'] ?? '';
4164 - $provider_name = 'Google Gemini';
4165 - } else {
4166 - $api_key = $options['api_key'] ?? '';
4167 - $provider_name = 'OpenAI';
4168 - }
4169 -
4170 - if (empty($api_key)) {
4171 - MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing');
4172 - wp_send_json_error($provider_name . ' API key not configured');
4173 - exit;
4174 - }
4175 -
4176 - $source_url = get_permalink($post_id);
4177 - $vector_id = md5($source_url); // Vector ID for Pinecone
4178 -
4179 - // Check for existing content in bot-specific storage
4180 - $is_update = false;
4181 -
4182 - // Get bot-specific Pinecone configuration
4183 - $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4184 - $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
4185 -
4186 - if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
4187 - // Check Pinecone for this bot
4188 - $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
4189 - if (isset($pinecone_data[$post_id])) {
4190 - $is_update = true;
4191 - }
4192 - } else {
4193 - // Check WordPress DB (same as before since it's shared)
4194 - global $wpdb;
4195 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4196 - $existing_record = $wpdb->get_row($wpdb->prepare(
4197 - "SELECT id FROM $table_name WHERE source_url = %s",
4198 - $source_url
4199 - ));
4200 -
4201 - if ($existing_record) {
4202 - $is_update = true;
4203 - }
4204 - }
4205 -
4206 - // UPDATED 2.5.6: Determine content type based on post_type
4207 - $post_type = $post->post_type;
4208 - $content_type = 'content'; // Default fallback
4209 -
4210 - // Map WordPress post types to content types
4211 - switch ($post_type) {
4212 - case 'post':
4213 - $content_type = 'post';
4214 - break;
4215 - case 'page':
4216 - $content_type = 'page';
4217 - break;
4218 - case 'product':
4219 - $content_type = 'product';
4220 - break;
4221 - default:
4222 - // For custom post types, use the post type name
4223 - $content_type = sanitize_key($post_type);
4224 - break;
4225 - }
4226 -
4227 - // Use the centralized utility function with bot_id and content_type
4228 - $result = MxChat_Utils::submit_content_to_db(
4229 - $content,
4230 - $source_url,
4231 - $api_key,
4232 - $vector_id,
4233 - $bot_id,
4234 - $content_type
4235 - );
4236 -
4237 - if (is_wp_error($result)) {
4238 - MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
4239 - wp_send_json_error('Storage failed: ' . $result->get_error_message());
4240 - exit;
4241 - }
4242 -
4243 - // Automatically apply role restriction based on tags
4244 - $this->apply_role_restriction_to_post($post_id, $source_url);
4245 -
4246 - $operation_type = $is_update ? 'update' : 'new';
4247 -
4248 - // Count ACF fields for debugging
4249 - $acf_field_count = count($acf_fields);
4250 -
4251 - // Success response with minimal data
4252 - wp_send_json_success(array(
4253 - 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
4254 - 'post_id' => $post_id,
4255 - 'title' => $post->post_title,
4256 - 'operation_type' => $operation_type,
4257 - 'vector_id' => $vector_id,
4258 - 'acf_fields_found' => $acf_field_count,
4259 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4260 - 'content_preview' => substr($content, 0, 100) . '...',
4261 - 'bot_id' => $bot_id
4262 - ));
4263 - exit;
4264 -}
4265 -
4266 -private function apply_role_restriction_to_post($post_id, $source_url) {
4267 - // Get tag-role mappings
4268 - $mappings = get_option('mxchat_tag_role_mappings', array());
4269 -
4270 - if (empty($mappings)) {
4271 - return; // No mappings, leave as public
4272 - }
4273 -
4274 - // Get all tags for the post
4275 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
4276 -
4277 - if (empty($post_tags)) {
4278 - return; // No tags, leave as public
4279 - }
4280 -
4281 - // Determine the highest role restriction based on tags
4282 - $highest_role = 'public';
4283 - $role_hierarchy = array(
4284 - 'public' => 0,
4285 - 'logged_in' => 1,
4286 - 'subscriber' => 2,
4287 - 'contributor' => 3,
4288 - 'author' => 4,
4289 - 'editor' => 5,
4290 - 'administrator' => 6
4291 - );
4292 -
4293 - foreach ($post_tags as $tag_slug) {
4294 - if (isset($mappings[$tag_slug])) {
4295 - $role = $mappings[$tag_slug];
4296 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
4297 - $highest_role = $role;
4298 - }
4299 - }
4300 - }
4301 -
4302 - // If no restricted tags found, return (leave as public)
4303 - if ($highest_role === 'public') {
4304 - return;
4305 - }
4306 -
4307 - // Update the role restriction in the database
4308 - global $wpdb;
4309 -
4310 - // Check if using Pinecone
4311 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4312 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4313 -
4314 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4315 - // Update Pinecone role restriction
4316 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4317 - $vector_id = md5($source_url);
4318 -
4319 - $wpdb->replace(
4320 - $roles_table,
4321 - array(
4322 - 'vector_id' => $vector_id,
4323 - 'role_restriction' => $highest_role,
4324 - 'updated_at' => current_time('mysql')
4325 - ),
4326 - array('%s', '%s', '%s')
4327 - );
4328 - } else {
4329 - // Update WordPress DB
4330 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4331 -
4332 - $wpdb->update(
4333 - $table_name,
4334 - array('role_restriction' => $highest_role),
4335 - array('source_url' => $source_url),
4336 - array('%s'),
4337 - array('%s')
4338 - );
4339 - }
4340 -}
4341 -
4342 -public function mxchat_get_public_post_types() {
4343 - // Get all public post types
4344 - $post_types = get_post_types(array('public' => true), 'objects');
4345 - $post_type_options = array();
4346 -
4347 - foreach ($post_types as $post_type) {
4348 - $post_type_options[$post_type->name] = $post_type->label;
4349 - }
4350 -
4351 - // Also include common forum/community post types that might not be marked as public
4352 - $additional_types = array(
4353 - 'topic' => 'Forum Topics (bbPress)',
4354 - 'reply' => 'Forum Replies (bbPress)',
4355 - 'forum' => 'Forums (bbPress)',
4356 - 'wpforo_topic' => 'wpForo Topics',
4357 - 'wpforo_post' => 'wpForo Posts'
4358 - );
4359 -
4360 - foreach ($additional_types as $type_name => $type_label) {
4361 - if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
4362 - $post_type_options[$type_name] = $type_label;
4363 - }
4364 - }
4365 -
4366 - return $post_type_options;
4367 -}
4368 -
4369 -/**
4370 - * Retrieves processed content from Pinecone API
4371 - */
4372 -public function mxchat_get_pinecone_processed_content($pinecone_options) {
4373 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4374 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4375 -
4376 - if (empty($api_key) || empty($host)) {
4377 - return array();
4378 - }
4379 -
4380 - $pinecone_data = array();
4381 -
4382 - try {
4383 - // Always get fresh data from Pinecone
4384 - $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
4385 -
4386 - // Method 2: Final fallback - try stats endpoint (if available)
4387 - if (empty($pinecone_data)) {
4388 - $stats_url = "https://{$host}/describe_index_stats";
4389 -
4390 - $response = wp_remote_post($stats_url, array(
4391 - 'headers' => array(
4392 - 'Api-Key' => $api_key,
4393 - 'Content-Type' => 'application/json'
4394 - ),
4395 - 'body' => json_encode(array()),
4396 - 'timeout' => 30
4397 - ));
4398 -
4399 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4400 - $body = wp_remote_retrieve_body($response);
4401 - $stats_data = json_decode($body, true);
4402 - }
4403 - }
4404 -
4405 - } catch (Exception $e) {
4406 - // Log error but return fresh data only
4407 - }
4408 -
4409 - return $pinecone_data;
4410 -}
4411 -public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
4412 - //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
4413 -
4414 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4415 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4416 -
4417 - if (empty($api_key) || empty($host) || empty($vector_ids)) {
4418 - //error_log('DEBUG: Missing parameters for fetch by IDs');
4419 - return array();
4420 - }
4421 -
4422 - try {
4423 - $fetch_url = "https://{$host}/vectors/fetch";
4424 - //error_log('DEBUG: Fetch URL: ' . $fetch_url);
4425 - //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
4426 -
4427 - // Pinecone fetch API allows fetching specific vectors by ID
4428 - $fetch_data = array(
4429 - 'ids' => array_values($vector_ids)
4430 - );
4431 -
4432 - $response = wp_remote_post($fetch_url, array(
4433 - 'headers' => array(
4434 - 'Api-Key' => $api_key,
4435 - 'Content-Type' => 'application/json'
4436 - ),
4437 - 'body' => json_encode($fetch_data),
4438 - 'timeout' => 30
4439 - ));
4440 -
4441 - if (is_wp_error($response)) {
4442 - //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
4443 - return array();
4444 - }
4445 -
4446 - $response_code = wp_remote_retrieve_response_code($response);
4447 - //error_log('DEBUG: Fetch response code: ' . $response_code);
4448 -
4449 - if ($response_code !== 200) {
4450 - $error_body = wp_remote_retrieve_body($response);
4451 - //error_log('DEBUG: Fetch failed with body: ' . $error_body);
4452 - return array();
4453 - }
4454 -
4455 - $body = wp_remote_retrieve_body($response);
4456 - $data = json_decode($body, true);
4457 -
4458 - //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
4459 -
4460 - if (!isset($data['vectors'])) {
4461 - //error_log('DEBUG: No vectors key in response');
4462 - return array();
4463 - }
4464 -
4465 - $processed_data = array();
4466 -
4467 - foreach ($data['vectors'] as $vector_id => $vector_data) {
4468 - $metadata = $vector_data['metadata'] ?? array();
4469 - $source_url = $metadata['source_url'] ?? '';
4470 -
4471 - if (!empty($source_url)) {
4472 - $post_id = url_to_postid($source_url);
4473 - if ($post_id) {
4474 - $created_at = $metadata['created_at'] ?? '';
4475 - $processed_date = 'Recently';
4476 -
4477 - if (!empty($created_at)) {
4478 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4479 - if ($timestamp) {
4480 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4481 - }
4482 - }
4483 -
4484 - $processed_data[$post_id] = array(
4485 - 'db_id' => $vector_id,
4486 - 'processed_date' => $processed_date,
4487 - 'url' => $source_url,
4488 - 'source' => 'pinecone',
4489 - 'timestamp' => $timestamp ?? current_time('timestamp')
4490 - );
4491 - }
4492 - }
4493 - }
4494 -
4495 - //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
4496 - return $processed_data;
4497 -
4498 - } catch (Exception $e) {
4499 - //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
4500 - return array();
4501 - }
4502 -}
4503 -
4504 -/**
4505 - * Get embedding dimensions based on the selected model.
4506 - */
4507 -private function mxchat_get_embedding_dimensions() {
4508 - $options = get_option('mxchat_options', array());
4509 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4510 -
4511 - $model_dimensions = array(
4512 - 'text-embedding-ada-002' => 1536,
4513 - 'text-embedding-3-small' => 1536,
4514 - 'text-embedding-3-large' => 3072,
4515 - 'voyage-2' => 1024,
4516 - 'voyage-large-2' => 1536,
4517 - 'voyage-3-large' => 2048,
4518 - 'gemini-embedding-001' => 1536,
4519 - );
4520 -
4521 - if (strpos($selected_model, 'voyage-3-large') === 0) {
4522 - $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
4523 - return intval($custom_dimensions);
4524 - }
4525 -
4526 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4527 - $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
4528 - return intval($custom_dimensions);
4529 - }
4530 -
4531 - return $model_dimensions[$selected_model] ?? 1536;
4532 -}
4533 -
4534 -/**
4535 - * Scan Pinecone for processed content
4536 - */
4537 -public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4538 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4539 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4540 -
4541 - if (empty($api_key) || empty($host)) {
4542 - return array();
4543 - }
4544 -
4545 - try {
4546 - // Use multiple random vectors to get better coverage
4547 - $all_matches = array();
4548 - $seen_ids = array();
4549 -
4550 - // Get correct dimensions for the configured embedding model
4551 - $dimensions = $this->mxchat_get_embedding_dimensions();
4552 -
4553 - // Try 3 different random vectors to get better coverage
4554 - for ($i = 0; $i < 3; $i++) {
4555 - $query_url = "https://{$host}/query";
4556 -
4557 - // Generate a random unit vector instead of zeros
4558 - $random_vector = array();
4559 - for ($j = 0; $j < $dimensions; $j++) {
4560 - $random_vector[] = (rand(-1000, 1000) / 1000.0);
4561 - }
4562 -
4563 - // Normalize the vector to unit length
4564 - $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
4565 - if ($magnitude > 0) {
4566 - $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
4567 - }
4568 -
4569 - $query_data = array(
4570 - 'includeMetadata' => true,
4571 - 'includeValues' => false,
4572 - 'topK' => 10000,
4573 - 'vector' => $random_vector
4574 - );
4575 -
4576 - $response = wp_remote_post($query_url, array(
4577 - 'headers' => array(
4578 - 'Api-Key' => $api_key,
4579 - 'Content-Type' => 'application/json'
4580 - ),
4581 - 'body' => json_encode($query_data),
4582 - 'timeout' => 30
4583 - ));
4584 -
4585 - if (is_wp_error($response)) {
4586 - continue;
4587 - }
4588 -
4589 - $response_code = wp_remote_retrieve_response_code($response);
4590 -
4591 - if ($response_code !== 200) {
4592 - continue;
4593 - }
4594 -
4595 - $body = wp_remote_retrieve_body($response);
4596 - $data = json_decode($body, true);
4597 -
4598 - if (isset($data['matches'])) {
4599 - foreach ($data['matches'] as $match) {
4600 - $match_id = $match['id'] ?? '';
4601 - if (!empty($match_id) && !isset($seen_ids[$match_id])) {
4602 - $all_matches[] = $match;
4603 - $seen_ids[$match_id] = true;
4604 - }
4605 - }
4606 - }
4607 - }
4608 -
4609 - // Convert matches to processed data format, grouping by URL to count chunks
4610 - $processed_data = array();
4611 - $url_chunk_counts = array();
4612 -
4613 - foreach ($all_matches as $match) {
4614 - $metadata = $match['metadata'] ?? array();
4615 - $source_url = $metadata['source_url'] ?? '';
4616 - $match_id = $match['id'] ?? '';
4617 -
4618 - if (!empty($source_url) && !empty($match_id)) {
4619 - $post_id = url_to_postid($source_url);
4620 - if ($post_id) {
4621 - // Count chunks per post_id
4622 - if (!isset($url_chunk_counts[$post_id])) {
4623 - $url_chunk_counts[$post_id] = 0;
4624 - }
4625 - $url_chunk_counts[$post_id]++;
4626 -
4627 - $created_at = $metadata['created_at'] ?? '';
4628 - $processed_date = 'Recently';
4629 -
4630 - if (!empty($created_at)) {
4631 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4632 - if ($timestamp) {
4633 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4634 - }
4635 - }
4636 -
4637 - // Only store if not already set, or update with newer timestamp
4638 - if (!isset($processed_data[$post_id]) ||
4639 - ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
4640 - $processed_data[$post_id] = array(
4641 - 'db_id' => $match_id,
4642 - 'processed_date' => $processed_date,
4643 - 'url' => $source_url,
4644 - 'source' => 'pinecone',
4645 - 'timestamp' => $timestamp ?? current_time('timestamp')
4646 - );
4647 - }
4648 - }
4649 - }
4650 - }
4651 -
4652 - // Add chunk counts to processed data
4653 - foreach ($url_chunk_counts as $post_id => $chunk_count) {
4654 - if (isset($processed_data[$post_id])) {
4655 - $processed_data[$post_id]['chunk_count'] = $chunk_count;
4656 - }
4657 - }
4658 -
4659 - return $processed_data;
4660 -
4661 - } catch (Exception $e) {
4662 - return array();
4663 - }
4664 -}
4665 -/**
4666 - * Generate embeddings from input text for MXChat with bot support
4667 - */
4668 -private function mxchat_generate_embedding($text, $bot_id = 'default') {
4669 - // Enable detailed logging for debugging
4670 - //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
4671 - //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
4672 -
4673 - // Get bot-specific options
4674 - $bot_options = $this->get_bot_options($bot_id);
4675 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4676 -
4677 - // Opt-in: when the custom provider is selected for embeddings, index through
4678 - // the same custom endpoint the query path uses so stored vectors and query
4679 - // vectors share a model. Returns the vector array on success, or an error
4680 - // string on failure (this function's existing failure contract).
4681 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4682 - if (!class_exists('MxChat_Utils')) {
4683 - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4684 - }
4685 - return MxChat_Utils::generate_embedding_custom($text, $options);
4686 - }
4687 -
4688 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4689 - //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4690 -
4691 - // Determine provider and endpoint
4692 - if (strpos($selected_model, 'voyage') === 0) {
4693 - $api_key = $options['voyage_api_key'] ?? '';
4694 - $endpoint = 'https://api.voyageai.com/v1/embeddings';
4695 - $provider_name = 'Voyage AI';
4696 - //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
4697 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4698 - $api_key = $options['gemini_api_key'] ?? '';
4699 - $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4700 - $provider_name = 'Google Gemini';
4701 - //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
4702 - } else {
4703 - $api_key = $options['api_key'] ?? '';
4704 - $endpoint = 'https://api.openai.com/v1/embeddings';
4705 - $provider_name = 'OpenAI';
4706 - //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
4707 - }
4708 -
4709 - //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
4710 -
4711 - if (empty($api_key)) {
4712 - $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
4713 - //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
4714 - return $error_message;
4715 - }
4716 -
4717 - // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
4718 - $estimated_tokens = ceil(str_word_count($text) / 0.75);
4719 - //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
4720 -
4721 - if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
4722 - //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
4723 - // Consider truncating text here
4724 - }
4725 -
4726 - // Prepare request body based on provider
4727 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4728 - // Gemini API format
4729 - $request_body = array(
4730 - 'model' => 'models/' . $selected_model,
4731 - 'content' => array(
4732 - 'parts' => array(
4733 - array('text' => $text)
4734 - )
4735 - )
4736 - );
4737 -
4738 - // Set output dimensionality to 1536 for consistency with other models
4739 - $request_body['outputDimensionality'] = 1536;
4740 - } else {
4741 - // OpenAI/Voyage API format
4742 - $request_body = array(
4743 - 'model' => $selected_model,
4744 - 'input' => $text
4745 - );
4746 -
4747 - // Add output_dimension for voyage-3-large model
4748 - if ($selected_model === 'voyage-3-large') {
4749 - $request_body['output_dimension'] = 2048;
4750 - }
4751 - }
4752 -
4753 - //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
4754 -
4755 - // Prepare headers based on provider
4756 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4757 - // Gemini uses API key as query parameter
4758 - $endpoint .= '?key=' . $api_key;
4759 - $headers = array(
4760 - 'Content-Type' => 'application/json'
4761 - );
4762 - } else {
4763 - // OpenAI/Voyage use Bearer token
4764 - $headers = array(
4765 - 'Authorization' => 'Bearer ' . $api_key,
4766 - 'Content-Type' => 'application/json'
4767 - );
4768 - }
4769 -
4770 - // Make API request
4771 - //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
4772 - $response = wp_remote_post($endpoint, array(
4773 - 'body' => wp_json_encode($request_body),
4774 - 'headers' => $headers,
4775 - 'timeout' => 60 // Increased timeout for large inputs
4776 - ));
4777 -
4778 - // Handle wp_remote_post errors
4779 - if (is_wp_error($response)) {
4780 - $error_message = $response->get_error_message();
4781 - //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
4782 - return 'Connection error: ' . $error_message;
4783 - }
4784 -
4785 - // Get and check HTTP response code
4786 - $http_code = wp_remote_retrieve_response_code($response);
4787 - //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
4788 -
4789 - if ($http_code !== 200) {
4790 - $error_body = wp_remote_retrieve_body($response);
4791 - //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
4792 -
4793 - // Try to parse error for more details
4794 - $error_json = json_decode($error_body, true);
4795 - if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
4796 - $error_type = $error_json['error']['type'] ?? 'unknown';
4797 - $error_message = $error_json['error']['message'] ?? 'No message';
4798 - //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
4799 - //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
4800 -
4801 - // Customize error message for common API errors
4802 - if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
4803 - $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
4804 - } elseif ($error_type === 'authentication_error') {
4805 - $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
4806 - }
4807 -
4808 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4809 - return $error_message;
4810 - }
4811 -
4812 - $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
4813 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4814 - return $error_message;
4815 - }
4816 -
4817 - // Parse response body
4818 - $response_body = wp_remote_retrieve_body($response);
4819 - //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
4820 -
4821 - $response_data = json_decode($response_body, true);
4822 -
4823 - if (json_last_error() !== JSON_ERROR_NONE) {
4824 - $error = json_last_error_msg();
4825 - //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
4826 - //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
4827 - return "Failed to parse API response: $error";
4828 - }
4829 -
4830 - // Handle different response formats based on provider
4831 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4832 - // Gemini API response format
4833 - if (isset($response_data['embedding']['values'])) {
4834 - $embedding_dimensions = count($response_data['embedding']['values']);
4835 - //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
4836 -
4837 - // Check if embedding dimensions are as expected (should be 1536)
4838 - if ($embedding_dimensions !== 1536) {
4839 - //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
4840 - }
4841 -
4842 - return $response_data['embedding']['values'];
4843 - } else {
4844 - //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
4845 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4846 -
4847 - if (isset($response_data['error'])) {
4848 - $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
4849 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4850 - return $error_message;
4851 - }
4852 -
4853 - $error_message = "Invalid Gemini API response format: No embedding found";
4854 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4855 - return $error_message;
4856 - }
4857 - } else {
4858 - // OpenAI/Voyage API response format
4859 - if (isset($response_data['data'][0]['embedding'])) {
4860 - $embedding_dimensions = count($response_data['data'][0]['embedding']);
4861 - //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
4862 -
4863 - // Check if embedding dimensions are as expected
4864 - if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
4865 - ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
4866 - //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
4867 - }
4868 -
4869 - return $response_data['data'][0]['embedding'];
4870 - } else {
4871 - //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
4872 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4873 -
4874 - if (isset($response_data['error'])) {
4875 - $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
4876 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4877 - return $error_message;
4878 - }
4879 -
4880 - $error_message = "Invalid API response format: No embedding found";
4881 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4882 - return $error_message;
4883 - }
4884 - }
4885 -}
4886 -
4887 -/**
4888 - * Get bot-specific options for multi-bot functionality
4889 - * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
4890 - */
4891 -private function get_bot_options($bot_id = 'default') {
4892 - //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
4893 -
4894 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4895 - //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
4896 - return array();
4897 - }
4898 -
4899 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
4900 -
4901 - if (!empty($bot_options)) {
4902 - //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
4903 - if (isset($bot_options['similarity_threshold'])) {
4904 - //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
4905 - }
4906 - }
4907 -
4908 - return is_array($bot_options) ? $bot_options : array();
4909 -}
4910 -
4911 -/**
4912 - * Get bot-specific Pinecone configuration
4913 - * Used in the knowledge retrieval functions
4914 - */
4915 -// Also add debugging to your get_bot_pinecone_config function
4916 -private function get_bot_pinecone_config($bot_id = 'default') {
4917 - //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4918 -
4919 - // If default bot or multi-bot add-on not active, use default Pinecone config
4920 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4921 - //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4922 - $addon_options = get_option('mxchat_pinecone_addon_options', array());
4923 - $config = array(
4924 - 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4925 - 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4926 - 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4927 - 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4928 - );
4929 - //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4930 - return $config;
4931 - }
4932 -
4933 - //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4934 -
4935 - // Hook for multi-bot add-on to provide bot-specific Pinecone config
4936 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4937 -
4938 - if (!empty($bot_pinecone_config)) {
4939 - //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4940 - //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4941 - //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4942 - //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4943 - } else {
4944 - //error_log("MXCHAT DEBUG: Filter returned empty config!");
4945 - }
4946 -
4947 - return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4948 -}
4949 -
4950 -
4951 -public function mxchat_ajax_dismiss_completed_status() {
4952 - try {
4953 - // Verify the request
4954 - check_ajax_referer('mxchat_status_nonce', 'nonce');
4955 -
4956 - if (!current_user_can('manage_options')) {
4957 - wp_send_json_error('Unauthorized access');
4958 - exit;
4959 - }
4960 -
4961 - $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
4962 -
4963 - if ($card_type === 'pdf') {
4964 - // Clear PDF status
4965 - $pdf_url = get_transient('mxchat_last_pdf_url');
4966 - if ($pdf_url) {
4967 - delete_transient('mxchat_pdf_status_' . md5($pdf_url));
4968 - delete_transient('mxchat_last_pdf_url');
4969 - }
4970 - } elseif ($card_type === 'sitemap') {
4971 - // Clear sitemap status
4972 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
4973 - if ($sitemap_url) {
4974 - delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
4975 - delete_transient('mxchat_last_sitemap_url');
4976 - }
4977 - }
4978 -
4979 - wp_send_json_success(array('message' => 'Status dismissed successfully'));
4980 -
4981 - } catch (Exception $e) {
4982 - wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
4983 - }
4984 -}
4985 -
4986 -/**
4987 - * Render completed status cards on page load
4988 - * This ensures completed processing status persists through page refreshes
4989 - */
4990 -public function mxchat_render_completed_status_cards() {
4991 - $output = '';
4992 -
4993 - // Check for completed PDF status
4994 - $pdf_url = get_transient('mxchat_last_pdf_url');
4995 - if ($pdf_url) {
4996 - $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
4997 - if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
4998 - $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
4999 - }
5000 - }
5001 -
5002 - // Check for completed sitemap status
5003 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
5004 - if ($sitemap_url) {
5005 - $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
5006 - if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
5007 - $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
5008 - }
5009 - }
5010 -
5011 - return $output;
5012 -}
5013 -
5014 -/**
5015 - * Render PDF status card HTML
5016 - */
5017 -private function mxchat_render_pdf_status_card($status, $pdf_url) {
5018 - $html = '<div class="mxchat-status-card" data-card-type="pdf">';
5019 - $html .= '<div class="mxchat-status-header">';
5020 - $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
5021 -
5022 - // Add dismiss button for completed status
5023 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
5024 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5025 - }
5026 -
5027 - // Process Batch button for processing status
5028 - if ($status['status'] === 'processing') {
5029 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
5030 - data-process-type="pdf"
5031 - data-url="' . esc_attr($pdf_url) . '">
5032 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5033 - }
5034 -
5035 - // Add status badges
5036 - if ($status['status'] === 'error') {
5037 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5038 - } elseif ($status['status'] === 'complete') {
5039 - if ($status['failed_pages'] > 0) {
5040 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5041 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
5042 - } else {
5043 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5044 - }
5045 - }
5046 -
5047 - $html .= '</div>'; // End header
5048 -
5049 - // Progress bar
5050 - $html .= '<div class="mxchat-progress-bar">';
5051 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5052 - $html .= '</div>';
5053 -
5054 - // Status details
5055 - $html .= '<div class="mxchat-status-details">';
5056 - $html .= '<p>' . sprintf(
5057 - esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
5058 - $status['processed_pages'],
5059 - $status['total_pages'],
5060 - $status['percentage']
5061 - ) . '</p>';
5062 -
5063 - // Show failed pages count if any
5064 - if ($status['failed_pages'] > 0) {
5065 - $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
5066 - }
5067 -
5068 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5069 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5070 -
5071 - // Add completion summary if available AND it's an array
5072 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5073 - $summary = $status['completion_summary'];
5074 - $html .= '<div class="mxchat-completion-summary">';
5075 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5076 - $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
5077 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
5078 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
5079 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5080 - $html .= '</div>';
5081 - }
5082 -
5083 - // Add failed pages list if any AND it's an array
5084 - if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
5085 - $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
5086 - }
5087 -
5088 - // Add error message if any
5089 - if (isset($status['error']) && !empty($status['error'])) {
5090 - $html .= '<div class="mxchat-error-notice">';
5091 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5092 - $html .= '</div>';
5093 - }
5094 -
5095 - $html .= '</div>'; // End details
5096 - $html .= '</div>'; // End card
5097 -
5098 - return $html;
5099 -}
5100 -/**
5101 - * Render sitemap status card HTML
5102 - */
5103 -private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
5104 - $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
5105 - $html .= '<div class="mxchat-status-header">';
5106 - $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
5107 -
5108 - // Add dismiss button for completed status
5109 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
5110 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5111 - }
5112 -
5113 - // Process Batch button for processing status
5114 - if ($status['status'] === 'processing') {
5115 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
5116 - data-process-type="sitemap"
5117 - data-url="' . esc_attr($sitemap_url) . '">
5118 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5119 - }
5120 -
5121 - // Add status badges
5122 - if ($status['status'] === 'error') {
5123 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5124 - } elseif ($status['status'] === 'complete') {
5125 - if ($status['failed_urls'] > 0) {
5126 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5127 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
5128 - } else {
5129 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5130 - }
5131 - }
5132 -
5133 - $html .= '</div>'; // End header
5134 -
5135 - // Progress bar
5136 - $html .= '<div class="mxchat-progress-bar">';
5137 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5138 - $html .= '</div>';
5139 -
5140 - // Status details
5141 - $html .= '<div class="mxchat-status-details">';
5142 - $html .= '<p>' . sprintf(
5143 - esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
5144 - $status['processed_urls'],
5145 - $status['total_urls'],
5146 - $status['percentage']
5147 - ) . '</p>';
5148 -
5149 - // Show failed URLs count if any
5150 - if ($status['failed_urls'] > 0) {
5151 - $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
5152 - }
5153 -
5154 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5155 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5156 -
5157 - // Add completion summary if available AND it's an array
5158 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5159 - $summary = $status['completion_summary'];
5160 - $html .= '<div class="mxchat-completion-summary">';
5161 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5162 - $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
5163 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
5164 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
5165 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5166 - $html .= '</div>';
5167 - }
5168 -
5169 - // Add error messages if any (but not the failed URLs list)
5170 - if (!empty($status['error']) || !empty($status['last_error'])) {
5171 - $html .= '<div class="mxchat-error-notice">';
5172 -
5173 - if (!empty($status['error'])) {
5174 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5175 - }
5176 -
5177 - if (!empty($status['last_error'])) {
5178 - $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
5179 - }
5180 -
5181 - $html .= '</div>';
5182 - }
5183 -
5184 - $html .= '</div>'; // End details
5185 - $html .= '</div>'; // End card
5186 -
5187 - return $html;
5188 -}
5189 -
5190 -
5191 -/**
5192 - * Render failed pages list
5193 - */
5194 -private function mxchat_render_failed_pages_list($failed_pages_list) {
5195 - // Validate that $failed_pages_list is an array and not empty
5196 - if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
5197 - return '';
5198 - }
5199 -
5200 - $html = '<div class="mxchat-error-notice">';
5201 - $html .= '<div class="mxchat-failed-pages-container">';
5202 - $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
5203 - $html .= '<details>';
5204 - $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
5205 - $html .= '<div class="mxchat-failed-pages-list">';
5206 -
5207 - // Create table for failed pages
5208 - $html .= '<table class="widefat striped">';
5209 - $html .= '<thead><tr>';
5210 - $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
5211 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5212 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5213 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5214 - $html .= '</tr></thead><tbody>';
5215 -
5216 - // Sort failed pages by most recent
5217 - $sorted_failed_pages = $failed_pages_list;
5218 - usort($sorted_failed_pages, function($a, $b) {
5219 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5220 - });
5221 -
5222 - foreach ($sorted_failed_pages as $item) {
5223 - // Ensure $item is an array before accessing its elements
5224 - if (!is_array($item)) {
5225 - continue;
5226 - }
5227 -
5228 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5229 - $html .= '<tr>';
5230 - $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
5231 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5232 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5233 - $html .= '<td>' . esc_html($time_ago) . '</td>';
5234 - $html .= '</tr>';
5235 - }
5236 -
5237 - $html .= '</tbody></table>';
5238 - $html .= '</div></details></div></div>';
5239 -
5240 - return $html;
5241 -}
5242 -
5243 -/**
5244 - * Render failed URLs list
5245 - */
5246 -private function mxchat_render_failed_urls_list($failed_urls_list) {
5247 - // Validate that $failed_urls_list is an array and not empty
5248 - if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
5249 - return '';
5250 - }
5251 -
5252 - $html = '<div class="mxchat-failed-urls-container">';
5253 - $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
5254 - $html .= '<details>';
5255 - $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
5256 - $html .= '<div class="mxchat-failed-urls-list">';
5257 -
5258 - // Create table for failed URLs
5259 - $html .= '<table class="widefat striped">';
5260 - $html .= '<thead><tr>';
5261 - $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
5262 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5263 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5264 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5265 - $html .= '</tr></thead><tbody>';
5266 -
5267 - // Sort failed URLs by most recent
5268 - $sorted_failed_urls = $failed_urls_list;
5269 - usort($sorted_failed_urls, function($a, $b) {
5270 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5271 - });
5272 -
5273 - // Show up to 50 failed URLs
5274 - $display_urls = array_slice($sorted_failed_urls, 0, 50);
5275 -
5276 - foreach ($display_urls as $item) {
5277 - // Ensure $item is an array before accessing its elements
5278 - if (!is_array($item)) {
5279 - continue;
5280 - }
5281 -
5282 - $url = $item['url'] ?? '';
5283 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5284 -
5285 - // Truncate URL for display
5286 - $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
5287 -
5288 - $html .= '<tr>';
5289 - $html .= '<td style="word-break: break-all;">';
5290 - if (!empty($url)) {
5291 - $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
5292 - } else {
5293 - $html .= esc_html__('Unknown URL', 'mxchat');
5294 - }
5295 - $html .= '</td>';
5296 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5297 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5298 - $html .= '<td>' . esc_html($time_ago) . '</td>';
5299 - $html .= '</tr>';
5300 - }
5301 -
5302 - $html .= '</tbody></table>';
5303 -
5304 - if (count($failed_urls_list) > 50) {
5305 - $html .= '<div class="mxchat-failed-urls-more">+ ' .
5306 - (count($failed_urls_list) - 50) .
5307 - ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
5308 - }
5309 -
5310 - $html .= '</div></details></div>';
5311 -
5312 - return $html;
5313 -}
5314 -
5315 -/**
5316 - * Get all ACF fields for a specific post, excluding any fields the user has disabled
5317 - */
5318 -public function mxchat_get_acf_fields_for_post($post_id) {
5319 - if (!function_exists('get_fields')) {
5320 - return array();
5321 - }
5322 -
5323 - $fields = get_fields($post_id);
5324 - if (!$fields || !is_array($fields)) {
5325 - return array();
5326 - }
5327 -
5328 - // Get excluded fields from settings
5329 - $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5330 - if (!empty($excluded_fields) && is_array($excluded_fields)) {
5331 - foreach ($excluded_fields as $excluded_field) {
5332 - if (isset($fields[$excluded_field])) {
5333 - unset($fields[$excluded_field]);
5334 - }
5335 - }
5336 - }
5337 -
5338 - return $fields;
5339 -}
5340 -
5341 -/**
5342 - * Get all registered ACF field groups and their fields for the settings UI
5343 - */
5344 -public function mxchat_get_all_acf_fields() {
5345 - if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5346 - return array();
5347 - }
5348 -
5349 - $all_fields = array();
5350 - $field_groups = acf_get_field_groups();
5351 -
5352 - if (!empty($field_groups)) {
5353 - foreach ($field_groups as $group) {
5354 - $group_fields = acf_get_fields($group['key']);
5355 - if (!empty($group_fields)) {
5356 - $all_fields[$group['title']] = array();
5357 - foreach ($group_fields as $field) {
5358 - $all_fields[$group['title']][] = array(
5359 - 'name' => $field['name'],
5360 - 'label' => $field['label'],
5361 - 'type' => $field['type']
5362 - );
5363 - }
5364 - }
5365 - }
5366 - }
5367 -
5368 - return $all_fields;
5369 -}
5370 -
5371 -/**
5372 - * Get whitelisted custom post meta for a given post
5373 - * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
5374 - */
5375 -public function mxchat_get_whitelisted_post_meta($post_id) {
5376 - $whitelist = get_option('mxchat_custom_meta_whitelist', '');
5377 -
5378 - if (empty($whitelist)) {
5379 - return array();
5380 - }
5381 -
5382 - // Parse the whitelist - one meta key per line
5383 - $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
5384 -
5385 - if (empty($meta_keys)) {
5386 - return array();
5387 - }
5388 -
5389 - $result = array();
5390 -
5391 - foreach ($meta_keys as $key) {
5392 - // Skip empty keys
5393 - if (empty($key)) {
5394 - continue;
5395 - }
5396 -
5397 - $value = get_post_meta($post_id, $key, true);
5398 -
5399 - // Only include non-empty string values
5400 - if (!empty($value) && is_string($value)) {
5401 - $result[$key] = $value;
5402 - } elseif (!empty($value) && is_array($value)) {
5403 - // Handle array values by joining them
5404 - $flat_value = $this->mxchat_flatten_meta_array($value);
5405 - if (!empty($flat_value)) {
5406 - $result[$key] = $flat_value;
5407 - }
5408 - }
5409 - }
5410 -
5411 - return $result;
5412 -}
5413 -
5414 -/**
5415 - * Flatten array meta values into a readable string
5416 - */
5417 -private function mxchat_flatten_meta_array($array, $depth = 0) {
5418 - if ($depth > 3) {
5419 - return ''; // Prevent infinite recursion
5420 - }
5421 -
5422 - $parts = array();
5423 -
5424 - foreach ($array as $key => $value) {
5425 - if (is_string($value) && !empty($value)) {
5426 - $parts[] = $value;
5427 - } elseif (is_array($value)) {
5428 - $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
5429 - if (!empty($nested)) {
5430 - $parts[] = $nested;
5431 - }
5432 - }
5433 - }
5434 -
5435 - return implode(', ', $parts);
5436 -}
5437 -
5438 -/**
5439 - * Format ACF field values for content extraction
5440 - */
5441 -public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
5442 - if (empty($value)) {
5443 - return '';
5444 - }
5445 -
5446 - // Handle WP_Post objects first (THIS IS THE KEY FIX)
5447 - if ($value instanceof WP_Post) {
5448 - return $value->post_title ?: '';
5449 - }
5450 -
5451 - // Handle other WP objects
5452 - if (is_object($value)) {
5453 - if (isset($value->post_title)) {
5454 - return $value->post_title;
5455 - } elseif (isset($value->display_name)) {
5456 - return $value->display_name;
5457 - } elseif (isset($value->name)) {
5458 - return $value->name;
5459 - } elseif (method_exists($value, '__toString')) {
5460 - try {
5461 - return (string) $value;
5462 - } catch (Exception $e) {
5463 - return '';
5464 - }
5465 - }
5466 - // For any other objects, return empty string
5467 - return '';
5468 - }
5469 -
5470 - // Handle different ACF field types
5471 - if (is_array($value)) {
5472 - // Check if it's an image/file field
5473 - if (isset($value['url'])) {
5474 - // Image field - return alt text, title, or caption
5475 - if (!empty($value['alt'])) {
5476 - return $value['alt'];
5477 - } elseif (!empty($value['title'])) {
5478 - return $value['title'];
5479 - } elseif (!empty($value['caption'])) {
5480 - return $value['caption'];
5481 - } else {
5482 - return ''; // Don't include just the URL
5483 - }
5484 - }
5485 -
5486 - // Check if it's a post object or relationship field
5487 - if (isset($value['post_title'])) {
5488 - return $value['post_title'];
5489 - }
5490 -
5491 - // Check if it's a user field
5492 - if (isset($value['display_name'])) {
5493 - return $value['display_name'];
5494 - }
5495 -
5496 - // Check if it's a taxonomy term
5497 - if (isset($value['name']) && isset($value['taxonomy'])) {
5498 - return $value['name'];
5499 - }
5500 -
5501 - // Check if it's a select field with label
5502 - if (isset($value['label'])) {
5503 - return $value['label'];
5504 - }
5505 -
5506 - // Check for repeater field or flexible content
5507 - if (is_numeric(key($value))) {
5508 - $sub_values = array();
5509 - foreach ($value as $sub_item) {
5510 - if (is_array($sub_item)) {
5511 - // For repeater/flexible content, extract text values
5512 - $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
5513 - if (!empty($sub_text)) {
5514 - $sub_values[] = $sub_text;
5515 - }
5516 - } elseif ($sub_item instanceof WP_Post) {
5517 - // Handle WP_Post objects in arrays
5518 - $sub_values[] = $sub_item->post_title ?: '';
5519 - } else {
5520 - $sub_values[] = (string) $sub_item;
5521 - }
5522 - }
5523 - return implode(', ', array_filter($sub_values));
5524 - }
5525 -
5526 - // For other arrays, try to extract meaningful text
5527 - $text_values = array();
5528 - foreach ($value as $key => $val) {
5529 - if (is_string($val) && !empty(trim($val))) {
5530 - $text_values[] = trim($val);
5531 - } elseif ($val instanceof WP_Post) {
5532 - // Handle WP_Post objects in associative arrays
5533 - $text_values[] = $val->post_title ?: '';
5534 - } elseif (is_array($val) && isset($val['post_title'])) {
5535 - $text_values[] = $val['post_title'];
5536 - } elseif (is_array($val) && isset($val['name'])) {
5537 - $text_values[] = $val['name'];
5538 - }
5539 - }
5540 -
5541 - return implode(', ', array_filter($text_values));
5542 - }
5543 -
5544 - // Handle boolean values
5545 - if (is_bool($value)) {
5546 - return $value ? 'Yes' : 'No';
5547 - }
5548 -
5549 - // Handle numeric values
5550 - if (is_numeric($value)) {
5551 - return (string) $value;
5552 - }
5553 -
5554 - // Handle string values
5555 - if (is_string($value)) {
5556 - return trim($value);
5557 - }
5558 -
5559 - // For anything else that we can't handle, return empty string
5560 - // This prevents the "Object could not be converted to string" error
5561 - return '';
5562 -}
5563 -
5564 -/**
5565 - * Extract text from complex ACF array structures
5566 - */
5567 -private function mxchat_extract_text_from_acf_array($array) {
5568 - if (!is_array($array)) {
5569 - return '';
5570 - }
5571 -
5572 - $text_parts = array();
5573 -
5574 - foreach ($array as $key => $value) {
5575 - if (is_string($value) && !empty(trim($value))) {
5576 - // Skip keys that are likely to be IDs or technical values
5577 - if (!is_numeric($value) || strlen($value) > 10) {
5578 - $text_parts[] = trim($value);
5579 - }
5580 - } elseif ($value instanceof WP_Post) {
5581 - // Handle WP_Post objects
5582 - $text_parts[] = $value->post_title ?: '';
5583 - } elseif (is_array($value)) {
5584 - if (isset($value['post_title'])) {
5585 - $text_parts[] = $value['post_title'];
5586 - } elseif (isset($value['name'])) {
5587 - $text_parts[] = $value['name'];
5588 - } elseif (isset($value['label'])) {
5589 - $text_parts[] = $value['label'];
5590 - }
5591 - } elseif (is_object($value)) {
5592 - // Handle other objects safely
5593 - if (isset($value->post_title)) {
5594 - $text_parts[] = $value->post_title;
5595 - } elseif (isset($value->name)) {
5596 - $text_parts[] = $value->name;
5597 - } elseif (isset($value->display_name)) {
5598 - $text_parts[] = $value->display_name;
5599 - }
5600 - }
5601 - }
5602 -
5603 - return implode(', ', array_filter($text_parts));
5604 -}
5605 -
5606 -/**
5607 - * Walk an ACF field value tree and collect attachment IDs for any value that
5608 - * resolves to a PDF in the WordPress media library. Handles the three shapes
5609 - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5610 - * plain URL string), and recurses through repeater/group/flexible content.
5611 - *
5612 - * @param mixed $value The ACF field value (any depth)
5613 - * @param array $out Accumulator (passed by reference) for attachment IDs
5614 - * @param int $depth Recursion guard
5615 - */
5616 -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5617 - if ($depth > 6) {
5618 - return; // prevent runaway recursion on circular/very-deep structures
5619 - }
5620 -
5621 - if (empty($value)) {
5622 - return;
5623 - }
5624 -
5625 - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5626 - if (is_array($value)) {
5627 - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5628 - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5629 - if ($looks_like_attachment) {
5630 - $att_id = 0;
5631 - if (!empty($value['ID']) && is_numeric($value['ID'])) {
5632 - $att_id = (int) $value['ID'];
5633 - } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5634 - $att_id = (int) $value['id'];
5635 - } elseif (!empty($value['url']) && is_string($value['url'])) {
5636 - $att_id = (int) attachment_url_to_postid($value['url']);
5637 - }
5638 -
5639 - $is_pdf = false;
5640 - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5641 - $is_pdf = true;
5642 - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5643 - $is_pdf = true;
5644 - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5645 - $is_pdf = true;
5646 - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5647 - $is_pdf = true;
5648 - }
5649 -
5650 - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5651 - $out[] = $att_id;
5652 - }
5653 - // An array node that represents one attachment doesn't contain other
5654 - // attachments inside it — done with this branch.
5655 - return;
5656 - }
5657 -
5658 - // Recurse: repeater rows, flexible-content layouts, groups, etc.
5659 - foreach ($value as $sub) {
5660 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5661 - }
5662 - return;
5663 - }
5664 -
5665 - // Plain numeric attachment ID (ACF File field set to "Return: ID")
5666 - if (is_numeric($value)) {
5667 - $att_id = (int) $value;
5668 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5669 - $out[] = $att_id;
5670 - }
5671 - return;
5672 - }
5673 -
5674 - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5675 - if (is_string($value)) {
5676 - $trimmed = trim($value);
5677 - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5678 - $att_id = (int) attachment_url_to_postid($trimmed);
5679 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5680 - $out[] = $att_id;
5681 - }
5682 - }
5683 - return;
5684 - }
5685 -}
5686 -
5687 -/**
5688 - * Heuristic: does this URL/string look like a PDF reference?
5689 - * Tolerates query strings and fragments (#page=2).
5690 - */
5691 -private function mxchat_url_looks_like_pdf($url) {
5692 - if (!is_string($url) || $url === '') {
5693 - return false;
5694 - }
5695 - // Strip query + fragment before checking extension
5696 - $path = preg_replace('/[?#].*$/', '', $url);
5697 - return (bool) preg_match('/\.pdf$/i', $path);
5698 -}
5699 -
5700 -/**
5701 - * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5702 - * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5703 - * Result is cached on the attachment as post_meta keyed by file mtime so we
5704 - * only parse the same PDF once unless the file changes on disk.
5705 - *
5706 - * @param int $attachment_id
5707 - * @return string Extracted plain text, or '' on failure.
5708 - */
5709 -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5710 - $attachment_id = (int) $attachment_id;
5711 - if ($attachment_id <= 0) {
5712 - return '';
5713 - }
5714 - if (get_post_mime_type($attachment_id) !== 'application/pdf') {
5715 - return '';
5716 - }
5717 -
5718 - $pdf_path = get_attached_file($attachment_id);
5719 - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
5720 - return '';
5721 - }
5722 -
5723 - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
5724 - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
5725 - $default_max_bytes = 25 * 1024 * 1024;
5726 - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
5727 - if ($max_bytes > 0) {
5728 - $file_size = @filesize($pdf_path);
5729 - if ($file_size !== false && $file_size > $max_bytes) {
5730 - error_log(sprintf(
5731 - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
5732 - $attachment_id,
5733 - basename($pdf_path),
5734 - $file_size,
5735 - $max_bytes
5736 - ));
5737 - return '';
5738 - }
5739 - }
5740 -
5741 - $mtime = @filemtime($pdf_path);
5742 - $cache_meta_key = '_mxchat_acf_pdf_text_v1';
5743 - $cached = get_post_meta($attachment_id, $cache_meta_key, true);
5744 - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
5745 - return (string) $cached['text'];
5746 - }
5747 -
5748 - $text = '';
5749 - try {
5750 - if (function_exists('mxchat_load_pdf_parser')) {
5751 - mxchat_load_pdf_parser();
5752 - }
5753 - if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
5754 - return '';
5755 - }
5756 - $parser = new \Smalot\PdfParser\Parser();
5757 - $pdf = $parser->parseFile($pdf_path);
5758 - $pages = $pdf->getPages();
5759 - $page_texts = array();
5760 - foreach ($pages as $page) {
5761 - $page_text = '';
5762 - try {
5763 - $page_text = $page->getText();
5764 - } catch (\Exception $e) {
5765 - $page_text = '';
5766 - }
5767 - if (!empty($page_text)) {
5768 - $page_texts[] = $page_text;
5769 - }
5770 - }
5771 - $text = trim(implode("\n\n", $page_texts));
5772 - } catch (\Exception $e) {
5773 - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
5774 - return '';
5775 - } catch (\Throwable $e) {
5776 - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
5777 - return '';
5778 - }
5779 -
5780 - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
5781 - // The chunker downstream will still split this into multiple vectors.
5782 - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
5783 - if ($max_len > 0 && strlen($text) > $max_len) {
5784 - $text = substr($text, 0, $max_len);
5785 - }
5786 -
5787 - update_post_meta($attachment_id, $cache_meta_key, array(
5788 - 'mtime' => (int) $mtime,
5789 - 'text' => $text,
5790 - ));
5791 -
5792 - return $text;
5793 -}
5794 -
5795 -/**
5796 - * Handle ACF save - fires after ACF fields are saved
5797 - * This ensures ACF field data is available when syncing to knowledge base
5798 - */
5799 -public function mxchat_handle_acf_save($post_id) {
5800 - // Skip if not a valid post
5801 - if (!$post_id || $post_id === 'options') {
5802 - return;
5803 - }
5804 -
5805 - // Skip autosaves and revisions
5806 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5807 - return;
5808 - }
5809 -
5810 - $post = get_post($post_id);
5811 - if (!$post) {
5812 - return;
5813 - }
5814 -
5815 - $post_type = $post->post_type;
5816 -
5817 - // Check if sync is enabled for this post type
5818 - $should_sync = false;
5819 -
5820 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5821 - $should_sync = true;
5822 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5823 - $should_sync = true;
5824 - } else if ($post_type === 'product' && class_exists('WooCommerce')) {
5825 - // WooCommerce products - check if WooCommerce integration is enabled
5826 - $options = get_option('mxchat_options', array());
5827 - if (isset($options['enable_woocommerce_integration']) &&
5828 - ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
5829 - $should_sync = true;
5830 - }
5831 - } else {
5832 - // Check custom post types
5833 - $option_name = 'mxchat_auto_sync_' . $post_type;
5834 - if (get_option($option_name) === '1') {
5835 - $should_sync = true;
5836 - }
5837 - }
5838 -
5839 - if (!$should_sync) {
5840 - return;
5841 - }
5842 -
5843 - // Only process published posts
5844 - if ($post->post_status !== 'publish') {
5845 - return;
5846 - }
5847 -
5848 - // Check if this post has any ACF fields - if not, no need to re-sync
5849 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5850 - if (empty($acf_fields)) {
5851 - return;
5852 - }
5853 -
5854 - // Use a transient to prevent duplicate processing (post_updated may have already run)
5855 - $transient_key = 'mxchat_acf_synced_' . $post_id;
5856 - if (get_transient($transient_key)) {
5857 - return;
5858 - }
5859 - set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
5860 -
5861 - // Re-run the sync with ACF data now available
5862 - // We pass $update=true since this is effectively an update with ACF data
5863 - $this->mxchat_handle_post_update($post_id, $post, true);
5864 -}
5865 -
5866 -public function mxchat_handle_post_update($post_id, $post, $update) {
5867 - // Basic validation checks
5868 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5869 - return;
5870 - }
5871 -
5872 - $post_type = $post->post_type;
5873 -
5874 - // Check if sync is enabled for this post type
5875 - $should_sync = false;
5876 -
5877 - // Check built-in post types first
5878 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5879 - $should_sync = true;
5880 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5881 - $should_sync = true;
5882 - } else {
5883 - // Check custom post types
5884 - $option_name = 'mxchat_auto_sync_' . $post_type;
5885 - if (get_option($option_name) === '1') {
5886 - $should_sync = true;
5887 - }
5888 - }
5889 -
5890 - if (!$should_sync) {
5891 - return;
5892 - }
5893 -
5894 - // Check if we have stored the previous status and URL in our transients
5895 - $previous_status_key = 'mxchat_prev_status_' . $post_id;
5896 - $previous_status = get_transient($previous_status_key);
5897 -
5898 - $previous_url_key = 'mxchat_prev_url_' . $post_id;
5899 - $previous_url = get_transient($previous_url_key);
5900 -
5901 - // If the post was previously published but is now not published, remove from knowledge base
5902 - if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5903 - // Use the stored URL from when it was published, or fall back to current permalink
5904 - $source_url = $previous_url ?: get_permalink($post_id);
5905 -
5906 - if ($source_url) {
5907 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5908 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5909 - }
5910 -
5911 - // Clean up the transients and exit early
5912 - delete_transient($previous_status_key);
5913 - delete_transient($previous_url_key);
5914 - return;
5915 - }
5916 -
5917 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5918 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5919 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5920 - $current_url = get_permalink($post_id);
5921 - if ($current_url && $current_url !== $previous_url) {
5922 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5923 - }
5924 - }
5925 -
5926 - // Store the current status for next time (if this is an update)
5927 - if ($update) {
5928 - set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5929 -
5930 - // If the post is currently published, also store its URL
5931 - if ($post->post_status === 'publish') {
5932 - $current_url = get_permalink($post_id);
5933 - set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
5934 - }
5935 - }
5936 -
5937 - // Only process currently published content for adding/updating
5938 - if ($post->post_status === 'publish') {
5939 - // Get the source URL
5940 - $source_url = get_permalink($post_id);
5941 -
5942 - // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
5943 - $title = get_the_title($post_id);
5944 - $content = get_post_field('post_content', $post_id);
5945 - $excerpt = get_post_field('post_excerpt', $post_id);
5946 -
5947 - // Remove shortcode tags but preserve content inside them
5948 - $content = $this->strip_shortcode_tags_preserve_content($content);
5949 - $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
5950 -
5951 - // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
5952 - $content = wp_strip_all_tags($content);
5953 -
5954 - // Combine title, short description (if exists), and content
5955 - $final_content = $title . "\n\n";
5956 -
5957 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
5958 - if (!empty($excerpt)) {
5959 - $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
5960 - }
5961 -
5962 - $final_content .= $content;
5963 -
5964 - // For WooCommerce products, include pricing and product details
5965 - if ($post_type === 'product' && class_exists('WooCommerce')) {
5966 - $product = wc_get_product($post_id);
5967 -
5968 - if ($product) {
5969 - // Get pricing information
5970 - $regular_price = $product->get_regular_price();
5971 - $sale_price = $product->get_sale_price();
5972 - $price = $product->get_price();
5973 - $sku = $product->get_sku();
5974 -
5975 - // Get currency symbol
5976 - $currency_symbol = get_woocommerce_currency_symbol();
5977 -
5978 - // Add pricing information
5979 - $final_content .= "\n";
5980 - if (!empty($regular_price)) {
5981 - $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
5982 - } elseif (!empty($price)) {
5983 - $final_content .= "Price: " . $currency_symbol . $price . "\n";
5984 - }
5985 -
5986 - if (!empty($sale_price) && $sale_price !== $regular_price) {
5987 - $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5988 - }
5989 -
5990 - // Handle variable products - show price range
5991 - if ($product->is_type('variable')) {
5992 - $min_price = $product->get_variation_price('min');
5993 - $max_price = $product->get_variation_price('max');
5994 - if ($min_price !== $max_price) {
5995 - $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5996 - }
5997 - }
5998 -
5999 - if (!empty($sku)) {
6000 - $final_content .= "SKU: " . $sku . "\n";
6001 - }
6002 -
6003 - // Get product categories
6004 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
6005 - if (!empty($categories) && !is_wp_error($categories)) {
6006 - $final_content .= "Categories: " . implode(', ', $categories) . "\n";
6007 - }
6008 - }
6009 - }
6010 -
6011 - // For custom post types like job_listing, include additional fields
6012 - if ($post_type === 'job_listing') {
6013 - // Add job-specific meta if available
6014 - $job_location = get_post_meta($post_id, '_job_location', true);
6015 - if (!empty($job_location)) {
6016 - $final_content .= "\n\nLocation: " . $job_location;
6017 - }
6018 -
6019 - // Get job type terms
6020 - $job_types = get_the_terms($post_id, 'job_listing_type');
6021 - if (!empty($job_types) && !is_wp_error($job_types)) {
6022 - $types = array();
6023 - foreach ($job_types as $type) {
6024 - $types[] = $type->name;
6025 - }
6026 - $final_content .= "\n\nJob Type: " . implode(', ', $types);
6027 - }
6028 -
6029 - // Get company name if available
6030 - $company_name = get_post_meta($post_id, '_company_name', true);
6031 - if (!empty($company_name)) {
6032 - $final_content .= "\n\nCompany: " . $company_name;
6033 - }
6034 - }
6035 -
6036 - // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
6037 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6038 - if (!empty($acf_fields)) {
6039 - $acf_content_parts = array();
6040 - $pdf_attachment_ids = array();
6041 -
6042 - foreach ($acf_fields as $field_name => $field_value) {
6043 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
6044 - if (!empty($formatted_value)) {
6045 - // Convert field name to readable label
6046 - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
6047 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
6048 - }
6049 -
6050 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
6051 - }
6052 -
6053 - if (!empty($acf_content_parts)) {
6054 - $final_content .= "\n\n" . implode("\n", $acf_content_parts);
6055 - }
6056 -
6057 - // Gate the auto-sync PDF-extraction loop behind an opt-in option.
6058 - // Mirrors the per-batch checkbox the manual content selector has; the
6059 - // 25 MB size cap lives in the shared extractor so it applies in both
6060 - // paths regardless. Default OFF — re-parsing every ACF PDF on every
6061 - // editor save is expensive and most sites don't want it.
6062 - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
6063 - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
6064 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
6065 - $pdf_sections = array();
6066 - foreach ($pdf_attachment_ids as $att_id) {
6067 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
6068 - if (!empty($pdf_text)) {
6069 - $pdf_title = get_the_title($att_id);
6070 - $pdf_url = wp_get_attachment_url($att_id);
6071 - $header = 'PDF Attachment';
6072 - if (!empty($pdf_title)) {
6073 - $header .= ': ' . $pdf_title;
6074 - }
6075 - if (!empty($pdf_url)) {
6076 - $header .= ' (' . $pdf_url . ')';
6077 - }
6078 - $pdf_sections[] = $header . "\n" . $pdf_text;
6079 - }
6080 - }
6081 - if (!empty($pdf_sections)) {
6082 - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
6083 - }
6084 - }
6085 - }
6086 -
6087 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
6088 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
6089 - if (!empty($custom_meta)) {
6090 - $meta_content_parts = array();
6091 -
6092 - foreach ($custom_meta as $meta_key => $meta_value) {
6093 - // Convert meta key to readable label
6094 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
6095 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
6096 - }
6097 -
6098 - if (!empty($meta_content_parts)) {
6099 - $final_content .= "\n\n" . implode("\n", $meta_content_parts);
6100 - }
6101 - }
6102 -
6103 - // Get API key with proper model detection
6104 - $options = get_option('mxchat_options');
6105 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6106 -
6107 - if (strpos($selected_model, 'voyage') === 0) {
6108 - $api_key = $options['voyage_api_key'] ?? '';
6109 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6110 - $api_key = $options['gemini_api_key'] ?? '';
6111 - } else {
6112 - $api_key = $options['api_key'] ?? '';
6113 - }
6114 -
6115 - if (empty($api_key)) {
6116 - return;
6117 - }
6118 -
6119 - // Use the centralized utility function for storage
6120 - $result = MxChat_Utils::submit_content_to_db(
6121 - $final_content,
6122 - $source_url,
6123 - $api_key,
6124 - md5($source_url) // Vector ID for Pinecone
6125 - );
6126 -
6127 - // After successful storage, apply role restriction based on tags
6128 - if (!is_wp_error($result)) {
6129 - $this->apply_role_restriction_to_post($post_id, $source_url);
6130 - }
6131 - }
6132 -
6133 - // Clean up the stored previous status if not used above
6134 - if ($previous_status !== 'publish' || $post->post_status === 'publish') {
6135 - delete_transient($previous_status_key);
6136 - delete_transient($previous_url_key);
6137 - }
6138 -}
6139 -
6140 -/**
6141 - * Store the post status and URL before update to detect status transitions
6142 - * This runs before the post is actually updated in the database
6143 - */
6144 -public function mxchat_store_pre_update_status($post_id, $data) {
6145 - // Get the current post from database (before update)
6146 - $current_post = get_post($post_id);
6147 -
6148 - if ($current_post) {
6149 - // Store the current status temporarily
6150 - $status_key = 'mxchat_prev_status_' . $post_id;
6151 - set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
6152 -
6153 - // If the post is currently published, also store its URL
6154 - if ($current_post->post_status === 'publish') {
6155 - $url_key = 'mxchat_prev_url_' . $post_id;
6156 - $current_url = get_permalink($post_id);
6157 - set_transient($url_key, $current_url, HOUR_IN_SECONDS);
6158 - }
6159 - }
6160 -}
6161 -
6162 -public function mxchat_handle_post_delete($post_id) {
6163 - // Get post data before it's deleted
6164 - $post = get_post($post_id);
6165 -
6166 - // Basic validation
6167 - if (!$post || wp_is_post_revision($post_id)) {
6168 - return;
6169 - }
6170 -
6171 - $post_type = $post->post_type;
6172 -
6173 - // Check if sync is enabled for this post type
6174 - $should_sync = false;
6175 -
6176 - // Check built-in post types first
6177 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6178 - $should_sync = true;
6179 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6180 - $should_sync = true;
6181 - } else {
6182 - // Check custom post types
6183 - $option_name = 'mxchat_auto_sync_' . $post_type;
6184 - if (get_option($option_name) === '1') {
6185 - $should_sync = true;
6186 - }
6187 - }
6188 -
6189 - if (!$should_sync) {
6190 - return;
6191 - }
6192 -
6193 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
6194 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
6195 - // real vector IDs stored under the original URL.
6196 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6197 - if (!$source_url) {
6198 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
6199 - return;
6200 - }
6201 -
6202 - // Use chunk-aware deletion (handles both chunked and non-chunked content)
6203 - $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6204 -
6205 - if (is_wp_error($delete_result)) {
6206 - //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
6207 - }
6208 -
6209 - delete_transient('mxchat_prev_url_' . $post_id);
6210 - delete_transient('mxchat_prev_status_' . $post_id);
6211 -}
6212 -
6213 -/**
6214 - * Resolve the source URL for a post being trashed/deleted.
6215 - *
6216 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
6217 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
6218 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
6219 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
6220 - */
6221 -private function mxchat_resolve_pre_trash_url($post_id) {
6222 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
6223 - if (!empty($previous_url)) {
6224 - return $previous_url;
6225 - }
6226 -
6227 - $current = get_permalink($post_id);
6228 - if (!$current) {
6229 - return '';
6230 - }
6231 - return preg_replace('#__trashed(/?)$#', '$1', $current);
6232 -}
6233 -
6234 -
6235 -
6236 -public function mxchat_handle_product_change($post_id, $post, $update) {
6237 - if ($post->post_type !== 'product') {
6238 - return;
6239 - }
6240 -
6241 - if ($post->post_status === 'publish') {
6242 - add_action('shutdown', function() use ($post_id) {
6243 - $product = wc_get_product($post_id);
6244 - if ($product) {
6245 - $this->mxchat_store_product_embedding($product);
6246 - }
6247 - });
6248 - }
6249 -}
6250 -
6251 -/**
6252 - * Store WooCommerce product embeddings
6253 - */
6254 -private function mxchat_store_product_embedding($product) {
6255 - if (!isset($this->options['enable_woocommerce_integration']) ||
6256 - !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6257 - return;
6258 - }
6259 -
6260 - $source_url = get_permalink($product->get_id());
6261 - $product_id = $product->get_id();
6262 -
6263 - // Build product content
6264 - $title = $product->get_name();
6265 - $description = $product->get_description();
6266 - $short_description = $product->get_short_description();
6267 - $regular_price = $product->get_regular_price();
6268 - $sale_price = $product->get_sale_price();
6269 - $price = $product->get_price();
6270 - $sku = $product->get_sku();
6271 -
6272 - // Get currency symbol
6273 - $currency_symbol = get_woocommerce_currency_symbol();
6274 -
6275 - // Format content consistently
6276 - $content = $title . "\n\n";
6277 -
6278 - if (!empty($short_description)) {
6279 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
6280 - }
6281 -
6282 - if (!empty($description)) {
6283 - $content .= wp_strip_all_tags($description) . "\n\n";
6284 - }
6285 -
6286 - // Add pricing information
6287 - if (!empty($regular_price)) {
6288 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
6289 - } elseif (!empty($price)) {
6290 - $content .= "Price: " . $currency_symbol . $price . "\n";
6291 - }
6292 -
6293 - if (!empty($sale_price) && $sale_price !== $regular_price) {
6294 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
6295 - }
6296 -
6297 - // Handle variable products - show price range
6298 - if ($product->is_type('variable')) {
6299 - $min_price = $product->get_variation_price('min');
6300 - $max_price = $product->get_variation_price('max');
6301 - if ($min_price !== $max_price) {
6302 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
6303 - }
6304 - }
6305 -
6306 - if (!empty($sku)) {
6307 - $content .= "SKU: " . $sku . "\n";
6308 - }
6309 -
6310 - // Get product categories
6311 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
6312 - if (!empty($categories) && !is_wp_error($categories)) {
6313 - $content .= "Categories: " . implode(', ', $categories) . "\n";
6314 - }
6315 -
6316 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
6317 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
6318 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
6319 - foreach ($custom_tabs as $tab) {
6320 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6321 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6322 -
6323 - if (!empty($tab_title) && !empty($tab_content)) {
6324 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6325 - }
6326 - }
6327 - }
6328 -
6329 - // Also check for reusable/saved tabs applied to this product
6330 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
6331 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
6332 - // Get the saved tabs option
6333 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
6334 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
6335 - foreach ($applied_saved_tabs as $saved_tab_id) {
6336 - if (isset($saved_tabs[$saved_tab_id])) {
6337 - $tab = $saved_tabs[$saved_tab_id];
6338 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6339 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6340 -
6341 - if (!empty($tab_title) && !empty($tab_content)) {
6342 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6343 - }
6344 - }
6345 - }
6346 - }
6347 - }
6348 -
6349 - // Get API key with proper model detection
6350 - $options = get_option('mxchat_options');
6351 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6352 -
6353 - if (strpos($selected_model, 'voyage') === 0) {
6354 - $api_key = $options['voyage_api_key'] ?? '';
6355 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6356 - $api_key = $options['gemini_api_key'] ?? '';
6357 - } else {
6358 - $api_key = $options['api_key'] ?? '';
6359 - }
6360 -
6361 - if (empty($api_key)) {
6362 - //error_log('MxChat Auto-sync: No API key configured for embedding model');
6363 - return;
6364 - }
6365 -
6366 - // Use the centralized utility function for storage
6367 - $result = MxChat_Utils::submit_content_to_db(
6368 - $content,
6369 - $source_url,
6370 - $api_key,
6371 - md5($source_url) // Vector ID for Pinecone
6372 - );
6373 -
6374 - // After successful storage, apply role restriction based on tags
6375 - if (!is_wp_error($result)) {
6376 - $this->apply_role_restriction_to_post($product_id, $source_url);
6377 - }
6378 -
6379 - if (is_wp_error($result)) {
6380 - //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
6381 - }
6382 -}
6383 -
6384 -public function mxchat_handle_product_delete($post_id) {
6385 - if (get_post_type($post_id) !== 'product') {
6386 - return;
6387 - }
6388 -
6389 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6390 - if (!$source_url) {
6391 - return;
6392 - }
6393 -
6394 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6395 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6396 -
6397 - delete_transient('mxchat_prev_url_' . $post_id);
6398 - delete_transient('mxchat_prev_status_' . $post_id);
6399 -}
6400 -
6401 -/**
6402 - * Handle individual Pinecone content deletion
6403 - */
6404 -public function mxchat_handle_pinecone_prompt_delete() {
6405 - // Check permissions
6406 - if (!current_user_can('manage_options')) {
6407 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
6408 - }
6409 -
6410 - // Verify nonce
6411 - if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
6412 - wp_die(esc_html__('Security check failed.', 'mxchat'));
6413 - }
6414 -
6415 - $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
6416 -
6417 - if (empty($vector_id)) {
6418 - set_transient('mxchat_admin_notice_error',
6419 - esc_html__('Invalid vector ID.', 'mxchat'),
6420 - 30
6421 - );
6422 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6423 - exit;
6424 - }
6425 -
6426 - // Get Pinecone settings
6427 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6428 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6429 -
6430 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6431 - set_transient('mxchat_admin_notice_error',
6432 - esc_html__('Pinecone is not properly configured.', 'mxchat'),
6433 - 30
6434 - );
6435 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6436 - exit;
6437 - }
6438 -
6439 - // Delete from Pinecone
6440 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6441 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
6442 - $vector_id,
6443 - $pinecone_options['mxchat_pinecone_api_key'],
6444 - $pinecone_options['mxchat_pinecone_host']
6445 - );
6446 -
6447 - if ($result['success']) {
6448 - // No cache clearing needed since we removed caching
6449 - set_transient('mxchat_admin_notice_success',
6450 - esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
6451 - 30
6452 - );
6453 - } else {
6454 - set_transient('mxchat_admin_notice_error',
6455 - esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
6456 - 30
6457 - );
6458 - }
6459 -
6460 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6461 - exit;
6462 -}
6463 -/**
6464 - * Handle individual Pinecone content deletion via AJAX
6465 - */
6466 -public function ajax_mxchat_delete_pinecone_prompt() {
6467 - // Verify nonce and permissions
6468 - if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
6469 - wp_send_json_error('Invalid nonce');
6470 - exit;
6471 - }
6472 -
6473 - if (!current_user_can('manage_options')) {
6474 - wp_send_json_error('Unauthorized access');
6475 - exit;
6476 - }
6477 -
6478 - $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
6479 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6480 -
6481 - if (empty($vector_id)) {
6482 - wp_send_json_error('Missing vector ID');
6483 - exit;
6484 - }
6485 -
6486 - // Get bot-specific Pinecone settings
6487 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6488 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6489 -
6490 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6491 -
6492 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6493 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
6494 - exit;
6495 - }
6496 -
6497 - // Delete from the correct Pinecone index
6498 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
6499 - $vector_id,
6500 - $pinecone_options['mxchat_pinecone_api_key'],
6501 - $pinecone_options['mxchat_pinecone_host']
6502 - );
6503 -
6504 - if ($result['success']) {
6505 - // No cache clearing needed since we removed caching
6506 - wp_send_json_success(array(
6507 - 'message' => 'Entry deleted successfully from Pinecone',
6508 - 'vector_id' => $vector_id,
6509 - 'bot_id' => $bot_id
6510 - ));
6511 - } else {
6512 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
6513 - wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
6514 - }
6515 -
6516 - exit;
6517 -}
6518 -
6519 -/**
6520 - * Handle deletion of all chunks for a given source URL via AJAX
6521 - * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
6522 - */
6523 -public function ajax_mxchat_delete_chunks_by_url() {
6524 - // Verify nonce and permissions
6525 - if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
6526 - wp_send_json_error('Invalid nonce');
6527 - exit;
6528 - }
6529 -
6530 - if (!current_user_can('manage_options')) {
6531 - wp_send_json_error('Unauthorized access');
6532 - exit;
6533 - }
6534 -
6535 - $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
6536 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
6537 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6538 -
6539 - if (empty($source_url)) {
6540 - wp_send_json_error('Missing source URL');
6541 - exit;
6542 - }
6543 -
6544 - // Generate the base vector ID from the source URL (same as how chunks are created)
6545 - $base_vector_id = md5($source_url);
6546 -
6547 - if ($data_source === 'pinecone') {
6548 - // Get bot-specific Pinecone settings (same as working delete function)
6549 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6550 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6551 -
6552 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6553 -
6554 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6555 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
6556 - exit;
6557 - }
6558 -
6559 - $api_key = $pinecone_options['mxchat_pinecone_api_key'];
6560 - $host = $pinecone_options['mxchat_pinecone_host'];
6561 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
6562 -
6563 - // Collect all vector IDs to delete
6564 - $vectors_to_delete = array();
6565 -
6566 - // Add the original single-vector ID (for non-chunked content)
6567 - $vectors_to_delete[] = $base_vector_id;
6568 -
6569 - // Use Pinecone list API to find all chunk vectors with this prefix
6570 - // NOTE: Pinecone List API is a GET request with query parameters, not POST
6571 - $prefix = $base_vector_id . '_chunk_';
6572 -
6573 - $query_params = array(
6574 - 'prefix' => $prefix,
6575 - 'limit' => 100
6576 - );
6577 -
6578 - if (!empty($namespace)) {
6579 - $query_params['namespace'] = $namespace;
6580 - }
6581 -
6582 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
6583 -
6584 - $list_response = wp_remote_get($list_url, array(
6585 - 'headers' => array(
6586 - 'Api-Key' => $api_key,
6587 - 'accept' => 'application/json'
6588 - ),
6589 - 'timeout' => 30
6590 - ));
6591 -
6592 - if (!is_wp_error($list_response)) {
6593 - $list_body_response = wp_remote_retrieve_body($list_response);
6594 - $list_data = json_decode($list_body_response, true);
6595 - if (!empty($list_data['vectors'])) {
6596 - foreach ($list_data['vectors'] as $vector) {
6597 - if (isset($vector['id'])) {
6598 - $vectors_to_delete[] = $vector['id'];
6599 - }
6600 - }
6601 - }
6602 - }
6603 -
6604 - if (empty($vectors_to_delete)) {
6605 - wp_send_json_success(array(
6606 - 'message' => 'No vectors found to delete',
6607 - 'source_url' => $source_url
6608 - ));
6609 - exit;
6610 - }
6611 -
6612 - // Delete all vectors using the same endpoint as the working function
6613 - $delete_url = "https://{$host}/vectors/delete";
6614 -
6615 - $delete_body = array(
6616 - 'ids' => $vectors_to_delete
6617 - );
6618 -
6619 - if (!empty($namespace)) {
6620 - $delete_body['namespace'] = $namespace;
6621 - }
6622 -
6623 - $delete_response = wp_remote_post($delete_url, array(
6624 - 'headers' => array(
6625 - 'Api-Key' => $api_key,
6626 - 'accept' => 'application/json',
6627 - 'content-type' => 'application/json'
6628 - ),
6629 - 'body' => wp_json_encode($delete_body),
6630 - 'timeout' => 30
6631 - ));
6632 -
6633 - if (is_wp_error($delete_response)) {
6634 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
6635 - wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
6636 - exit;
6637 - }
6638 -
6639 - $response_code = wp_remote_retrieve_response_code($delete_response);
6640 -
6641 - if ($response_code !== 200) {
6642 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
6643 - wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
6644 - exit;
6645 - }
6646 -
6647 - wp_send_json_success(array(
6648 - 'message' => 'All chunks deleted successfully from Pinecone',
6649 - 'source_url' => $source_url,
6650 - 'deleted_count' => count($vectors_to_delete)
6651 - ));
6652 -
6653 - } else {
6654 - // WordPress database deletion
6655 - global $wpdb;
6656 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6657 -
6658 - $result = $wpdb->delete(
6659 - $table_name,
6660 - array('source_url' => $source_url),
6661 - array('%s')
6662 - );
6663 -
6664 - if ($result === false) {
6665 - MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
6666 - wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
6667 - exit;
6668 - }
6669 -
6670 - wp_send_json_success(array(
6671 - 'message' => 'All chunks deleted successfully from database',
6672 - 'source_url' => $source_url,
6673 - 'deleted_count' => $result
6674 - ));
6675 - }
6676 -
6677 - exit;
6678 -}
6679 -
6680 -/**
6681 - * Handle individual WordPress database content deletion via AJAX
6682 - * Mirrors the Pinecone delete handler but for WordPress database entries
6683 - */
6684 -public function ajax_mxchat_delete_wordpress_prompt() {
6685 - // Verify nonce and permissions
6686 - if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
6687 - wp_send_json_error('Invalid nonce');
6688 - exit;
6689 - }
6690 -
6691 - if (!current_user_can('manage_options')) {
6692 - wp_send_json_error('Unauthorized access');
6693 - exit;
6694 - }
6695 -
6696 - $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
6697 -
6698 - if (empty($entry_id)) {
6699 - wp_send_json_error('Missing entry ID');
6700 - exit;
6701 - }
6702 -
6703 - global $wpdb;
6704 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6705 -
6706 - // Clear cache for this entry
6707 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
6708 -
6709 - // Delete from database
6710 - $result = $wpdb->delete(
6711 - $table_name,
6712 - array('id' => $entry_id),
6713 - array('%d')
6714 - );
6715 -
6716 - if ($result !== false) {
6717 - wp_send_json_success(array(
6718 - 'message' => 'Entry deleted successfully',
6719 - 'entry_id' => $entry_id
6720 - ));
6721 - } else {
6722 - wp_send_json_error('Failed to delete entry from database');
6723 - }
6724 -
6725 - exit;
6726 -}
6727 -
6728 -/**
6729 - * Handle bulk deletion of knowledge entries via AJAX
6730 - * Supports both Pinecone and WordPress database entries
6731 - */
6732 -public function ajax_mxchat_bulk_delete_knowledge() {
6733 - // Verify nonce and permissions
6734 - if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
6735 - wp_send_json_error('Invalid nonce');
6736 - exit;
6737 - }
6738 -
6739 - if (!current_user_can('manage_options')) {
6740 - wp_send_json_error('Unauthorized access');
6741 - exit;
6742 - }
6743 -
6744 - $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
6745 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6746 -
6747 - if (empty($entries) || !is_array($entries)) {
6748 - wp_send_json_error('No entries provided');
6749 - exit;
6750 - }
6751 -
6752 - // Extend execution time — bulk Pinecone operations can take a while
6753 - if (function_exists('set_time_limit')) {
6754 - set_time_limit(120);
6755 - }
6756 -
6757 - $success_ids = array();
6758 - $failed_ids = array();
6759 - $errors = array();
6760 -
6761 - global $wpdb;
6762 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6763 -
6764 - // Get Pinecone manager for Pinecone deletions
6765 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6766 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6767 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6768 -
6769 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
6770 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
6771 -
6772 - // =============================================
6773 - // PHASE 1: Collect all Pinecone vector IDs
6774 - // and separate WordPress entries
6775 - // =============================================
6776 - $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
6777 - $wordpress_entries = array(); // entries for WordPress DB deletion
6778 - $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
6779 -
6780 - foreach ($entries as $entry) {
6781 - $entry_id = sanitize_text_field($entry['id'] ?? '');
6782 - $source = sanitize_text_field($entry['source'] ?? 'wordpress');
6783 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
6784 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
6785 -
6786 - if (empty($entry_id)) {
6787 - continue;
6788 - }
6789 -
6790 - if ($source === 'pinecone') {
6791 - if (!$use_pinecone || empty($api_key)) {
6792 - $failed_ids[] = $entry_id;
6793 - $errors[] = "Pinecone not configured for entry: $entry_id";
6794 - continue;
6795 - }
6796 -
6797 - $pinecone_entry_ids[] = $entry_id;
6798 -
6799 - if ($is_group && !empty($source_url)) {
6800 - // Grouped/chunked entry: collect base ID + chunk IDs via List API
6801 - $base_vector_id = md5($source_url);
6802 - $all_vector_ids[] = $base_vector_id;
6803 -
6804 - $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
6805 - $list_response = wp_remote_get($list_url, array(
6806 - 'headers' => array(
6807 - 'Api-Key' => $api_key,
6808 - 'accept' => 'application/json'
6809 - ),
6810 - 'timeout' => 30
6811 - ));
6812 -
6813 - if (!is_wp_error($list_response)) {
6814 - $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
6815 - if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
6816 - foreach ($list_body['vectors'] as $vector) {
6817 - if (isset($vector['id'])) {
6818 - $all_vector_ids[] = $vector['id'];
6819 - }
6820 - }
6821 - }
6822 - }
6823 - } else {
6824 - // Single entry: the entry_id IS the vector ID
6825 - $all_vector_ids[] = $entry_id;
6826 - }
6827 - } else {
6828 - $wordpress_entries[] = $entry;
6829 - }
6830 - }
6831 -
6832 - // =============================================
6833 - // PHASE 2: Single batch delete to Pinecone
6834 - // =============================================
6835 - if (!empty($all_vector_ids)) {
6836 - $all_vector_ids = array_values(array_unique($all_vector_ids));
6837 - $pinecone_success = true;
6838 - $batches = array_chunk($all_vector_ids, 100);
6839 -
6840 - foreach ($batches as $batch) {
6841 - $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
6842 - 'headers' => array(
6843 - 'Api-Key' => $api_key,
6844 - 'accept' => 'application/json',
6845 - 'content-type' => 'application/json'
6846 - ),
6847 - 'body' => wp_json_encode(array('ids' => $batch)),
6848 - 'timeout' => 60
6849 - ));
6850 -
6851 - if (is_wp_error($delete_response)) {
6852 - $pinecone_success = false;
6853 - $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
6854 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
6855 - } else {
6856 - $response_code = wp_remote_retrieve_response_code($delete_response);
6857 - if ($response_code !== 200) {
6858 - $pinecone_success = false;
6859 - $response_body = wp_remote_retrieve_body($delete_response);
6860 - $errors[] = "Pinecone API error (HTTP $response_code)";
6861 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
6862 - }
6863 - }
6864 - }
6865 -
6866 - // Mark all pinecone entries based on batch result
6867 - foreach ($pinecone_entry_ids as $eid) {
6868 - if ($pinecone_success) {
6869 - $success_ids[] = $eid;
6870 - } else {
6871 - $failed_ids[] = $eid;
6872 - }
6873 - }
6874 - }
6875 -
6876 - // =============================================
6877 - // PHASE 3: WordPress database deletions
6878 - // =============================================
6879 - foreach ($wordpress_entries as $entry) {
6880 - $entry_id = sanitize_text_field($entry['id'] ?? '');
6881 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
6882 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
6883 -
6884 - if (empty($entry_id)) {
6885 - continue;
6886 - }
6887 -
6888 - try {
6889 - if ($is_group && !empty($source_url)) {
6890 - $result = $wpdb->delete(
6891 - $table_name,
6892 - array('source_url' => $source_url),
6893 - array('%s')
6894 - );
6895 - } else {
6896 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
6897 - $result = $wpdb->delete(
6898 - $table_name,
6899 - array('id' => intval($entry_id)),
6900 - array('%d')
6901 - );
6902 - }
6903 -
6904 - if ($result !== false) {
6905 - $success_ids[] = $entry_id;
6906 - } else {
6907 - $failed_ids[] = $entry_id;
6908 - $errors[] = "Database error for entry: $entry_id";
6909 - }
6910 - } catch (Exception $e) {
6911 - $failed_ids[] = $entry_id;
6912 - $errors[] = $e->getMessage();
6913 - }
6914 - }
6915 -
6916 - wp_send_json_success(array(
6917 - 'success_ids' => $success_ids,
6918 - 'failed_ids' => $failed_ids,
6919 - 'errors' => $errors,
6920 - 'total_processed' => count($success_ids) + count($failed_ids)
6921 - ));
6922 -
6923 - exit;
6924 -}
6925 -
6926 -/**
6927 - * Get hierarchical roles for dropdown
6928 - */
6929 -public function mxchat_get_role_options() {
6930 - return array(
6931 - 'public' => __('Public (Everyone)', 'mxchat'),
6932 - 'logged_in' => __('Logged In Users', 'mxchat'),
6933 - 'subscriber' => __('Subscribers & Above', 'mxchat'),
6934 - 'contributor' => __('Contributors & Above', 'mxchat'),
6935 - 'author' => __('Authors & Above', 'mxchat'),
6936 - 'editor' => __('Editors & Above', 'mxchat'),
6937 - 'administrator' => __('Administrators Only', 'mxchat')
6938 - );
6939 -}
6940 -
6941 -/**
6942 - * Check if user has access to content based on role restriction
6943 - */
6944 -public function mxchat_user_has_content_access($role_restriction) {
6945 - // Public content is always accessible
6946 - if ($role_restriction === 'public' || empty($role_restriction)) {
6947 - return true;
6948 - }
6949 -
6950 - // Check if user is logged in for logged_in restriction
6951 - if ($role_restriction === 'logged_in') {
6952 - return is_user_logged_in();
6953 - }
6954 -
6955 - // If not logged in, no access to role-restricted content
6956 - if (!is_user_logged_in()) {
6957 - return false;
6958 - }
6959 -
6960 - $user = wp_get_current_user();
6961 - $user_roles = $user->roles;
6962 -
6963 - if (empty($user_roles)) {
6964 - return false;
6965 - }
6966 -
6967 - // Define role hierarchy (higher number = higher access)
6968 - $hierarchy = array(
6969 - 'subscriber' => 1,
6970 - 'contributor' => 2,
6971 - 'author' => 3,
6972 - 'editor' => 4,
6973 - 'administrator' => 5
6974 - );
6975 -
6976 - // Get required level
6977 - $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
6978 -
6979 - // Check if user has required level or higher
6980 - foreach ($user_roles as $user_role) {
6981 - $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
6982 - if ($user_level >= $required_level) {
6983 - return true;
6984 - }
6985 - }
6986 -
6987 - return false;
6988 -}
6989 -
6990 -/**
6991 - * Handle role restriction updates via AJAX
6992 - * Removed cache clearing call since we removed caching
6993 - */
6994 -public function ajax_mxchat_update_role_restriction() {
6995 - // Verify nonce and permissions
6996 - if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
6997 - wp_send_json_error('Invalid nonce');
6998 - exit;
6999 - }
7000 -
7001 - if (!current_user_can('manage_options')) {
7002 - wp_send_json_error('Unauthorized access');
7003 - exit;
7004 - }
7005 -
7006 - $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
7007 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
7008 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
7009 -
7010 - if (empty($entry_id)) {
7011 - wp_send_json_error('Invalid entry ID');
7012 - exit;
7013 - }
7014 -
7015 - // Get knowledge manager instance to validate role restriction
7016 - $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
7017 - $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
7018 - if (!in_array($role_restriction, $valid_roles)) {
7019 - wp_send_json_error('Invalid role restriction');
7020 - exit;
7021 - }
7022 -
7023 - global $wpdb;
7024 -
7025 - if ($data_source === 'pinecone') {
7026 - // Handle Pinecone role restriction (stored separately in WordPress table)
7027 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7028 -
7029 - // Use REPLACE to insert or update the role restriction
7030 - $result = $wpdb->replace(
7031 - $roles_table,
7032 - array(
7033 - 'vector_id' => $entry_id,
7034 - 'role_restriction' => $role_restriction,
7035 - 'updated_at' => current_time('mysql')
7036 - ),
7037 - array('%s', '%s', '%s')
7038 - );
7039 -
7040 - // No cache clearing needed since we removed caching
7041 -
7042 - } else {
7043 - // Handle WordPress database role restriction (existing functionality)
7044 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7045 -
7046 - $result = $wpdb->update(
7047 - $table_name,
7048 - array('role_restriction' => $role_restriction),
7049 - array('id' => absint($entry_id)),
7050 - array('%s'),
7051 - array('%d')
7052 - );
7053 - }
7054 -
7055 - if ($result === false) {
7056 - wp_send_json_error('Database update failed: ' . $wpdb->last_error);
7057 - exit;
7058 - }
7059 -
7060 - wp_send_json_success(array(
7061 - 'message' => 'Role restriction updated successfully',
7062 - 'role_restriction' => $role_restriction,
7063 - 'data_source' => $data_source,
7064 - 'entry_id' => $entry_id
7065 - ));
7066 - exit;
7067 -}
7068 -
7069 -// ========================================
7070 -// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
7071 -// Add these to your MxChat_Knowledge_Manager class
7072 -// ========================================
7073 -
7074 -/**
7075 - * Initialize role-based content hooks
7076 - * Add this call to your __construct() or mxchat_init_hooks() method
7077 - */
7078 -private function mxchat_init_role_hooks() {
7079 - // AJAX handlers for tag-role mappings
7080 - add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
7081 - add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
7082 - add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
7083 - add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
7084 -
7085 - // Hook to automatically update role restrictions when tags are added/removed
7086 - add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
7087 -
7088 - // Hook to apply role restrictions on auto-sync
7089 - add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
7090 -}
7091 -
7092 -/**
7093 - * Add tag-role mapping via AJAX
7094 - */
7095 -public function ajax_add_tag_role_mapping() {
7096 - // Verify nonce and permissions
7097 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7098 -
7099 - if (!current_user_can('manage_options')) {
7100 - wp_send_json_error('Unauthorized access');
7101 - exit;
7102 - }
7103 -
7104 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
7105 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
7106 -
7107 - if (empty($tag_input)) {
7108 - wp_send_json_error('Please enter a tag name or slug');
7109 - exit;
7110 - }
7111 -
7112 - // Validate role restriction
7113 - $valid_roles = array_keys($this->mxchat_get_role_options());
7114 - if (!in_array($role_restriction, $valid_roles)) {
7115 - wp_send_json_error('Invalid role restriction');
7116 - exit;
7117 - }
7118 -
7119 - // Resolve the tag by slug first, then fall back to its display name, so users can
7120 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
7121 - // labeled by name but previously validated by slug only, producing the confusing
7122 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
7123 - $term = get_term_by('slug', $tag_input, 'post_tag');
7124 - if (!$term) {
7125 - $term = get_term_by('name', $tag_input, 'post_tag');
7126 - }
7127 - if (!$term) {
7128 - 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.');
7129 - exit;
7130 - }
7131 -
7132 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
7133 - // compares against each post's tag slugs, so the stored key must be a slug,
7134 - // never the raw (possibly display-name) input.
7135 - $tag_slug = $term->slug;
7136 -
7137 - // Get existing mappings
7138 - $mappings = get_option('mxchat_tag_role_mappings', array());
7139 -
7140 - // Check if mapping already exists
7141 - if (isset($mappings[$tag_slug])) {
7142 - wp_send_json_error('Mapping for this tag already exists');
7143 - exit;
7144 - }
7145 -
7146 - // Add new mapping
7147 - $mappings[$tag_slug] = $role_restriction;
7148 - update_option('mxchat_tag_role_mappings', $mappings);
7149 -
7150 - wp_send_json_success(array(
7151 - 'message' => 'Tag-role mapping added successfully',
7152 - 'tag_slug' => $tag_slug,
7153 - 'role_restriction' => $role_restriction
7154 - ));
7155 - exit;
7156 -}
7157 -
7158 -/**
7159 - * Delete tag-role mapping via AJAX
7160 - */
7161 -public function ajax_delete_tag_role_mapping() {
7162 - // Verify nonce and permissions
7163 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7164 -
7165 - if (!current_user_can('manage_options')) {
7166 - wp_send_json_error('Unauthorized access');
7167 - exit;
7168 - }
7169 -
7170 - $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
7171 -
7172 - if (empty($tag_slug)) {
7173 - wp_send_json_error('Tag slug is required');
7174 - exit;
7175 - }
7176 -
7177 - // Get existing mappings
7178 - $mappings = get_option('mxchat_tag_role_mappings', array());
7179 -
7180 - // Check if mapping exists
7181 - if (!isset($mappings[$tag_slug])) {
7182 - wp_send_json_error('Mapping does not exist');
7183 - exit;
7184 - }
7185 -
7186 - // Remove mapping
7187 - unset($mappings[$tag_slug]);
7188 - update_option('mxchat_tag_role_mappings', $mappings);
7189 -
7190 - wp_send_json_success(array(
7191 - 'message' => 'Tag-role mapping deleted successfully',
7192 - 'tag_slug' => $tag_slug
7193 - ));
7194 - exit;
7195 -}
7196 -
7197 -/**
7198 - * Get all tag-role mappings via AJAX
7199 - */
7200 -public function ajax_get_tag_role_mappings() {
7201 - // Verify nonce and permissions
7202 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7203 -
7204 - if (!current_user_can('manage_options')) {
7205 - wp_send_json_error('Unauthorized access');
7206 - exit;
7207 - }
7208 -
7209 - // Get mappings
7210 - $mappings = get_option('mxchat_tag_role_mappings', array());
7211 - $role_options = $this->mxchat_get_role_options();
7212 -
7213 - $formatted_mappings = array();
7214 -
7215 - foreach ($mappings as $tag_slug => $role_restriction) {
7216 - // Get tag object
7217 - $term = get_term_by('slug', $tag_slug, 'post_tag');
7218 -
7219 - // Count posts with this tag
7220 - $post_count = 0;
7221 - if ($term) {
7222 - $post_count = $term->count;
7223 - }
7224 -
7225 - $formatted_mappings[] = array(
7226 - 'tag_slug' => $tag_slug,
7227 - 'role_restriction' => $role_restriction,
7228 - 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
7229 - 'post_count' => $post_count
7230 - );
7231 - }
7232 -
7233 - wp_send_json_success(array(
7234 - 'mappings' => $formatted_mappings
7235 - ));
7236 - exit;
7237 -}
7238 -
7239 -/**
7240 - * Bulk update role restrictions for all existing content with mapped tags
7241 - */
7242 -public function ajax_bulk_update_tag_roles() {
7243 - // Verify nonce and permissions
7244 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7245 -
7246 - if (!current_user_can('manage_options')) {
7247 - wp_send_json_error('Unauthorized access');
7248 - exit;
7249 - }
7250 -
7251 - // Get mappings
7252 - $mappings = get_option('mxchat_tag_role_mappings', array());
7253 -
7254 - if (empty($mappings)) {
7255 - wp_send_json_error('No tag-role mappings found');
7256 - exit;
7257 - }
7258 -
7259 - global $wpdb;
7260 -
7261 - // Check if using Pinecone
7262 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7263 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7264 -
7265 - $updated_count = 0;
7266 - $details = array();
7267 -
7268 - foreach ($mappings as $tag_slug => $role_restriction) {
7269 - // Get all posts with this tag
7270 - $posts = get_posts(array(
7271 - 'tag' => $tag_slug,
7272 - 'post_type' => 'any',
7273 - 'posts_per_page' => -1,
7274 - 'fields' => 'ids',
7275 - 'post_status' => 'publish'
7276 - ));
7277 -
7278 - if (empty($posts)) {
7279 - continue;
7280 - }
7281 -
7282 - $tag_updated = 0;
7283 -
7284 - foreach ($posts as $post_id) {
7285 - $source_url = get_permalink($post_id);
7286 - if (!$source_url) {
7287 - continue;
7288 - }
7289 -
7290 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7291 - // Update Pinecone role restriction
7292 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7293 - $vector_id = md5($source_url);
7294 -
7295 - $result = $wpdb->replace(
7296 - $roles_table,
7297 - array(
7298 - 'vector_id' => $vector_id,
7299 - 'role_restriction' => $role_restriction,
7300 - 'updated_at' => current_time('mysql')
7301 - ),
7302 - array('%s', '%s', '%s')
7303 - );
7304 - } else {
7305 - // Update WordPress DB
7306 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7307 -
7308 - $result = $wpdb->update(
7309 - $table_name,
7310 - array('role_restriction' => $role_restriction),
7311 - array('source_url' => $source_url),
7312 - array('%s'),
7313 - array('%s')
7314 - );
7315 - }
7316 -
7317 - if ($result !== false) {
7318 - $tag_updated++;
7319 - $updated_count++;
7320 - }
7321 - }
7322 -
7323 - if ($tag_updated > 0) {
7324 - $details[] = sprintf(
7325 - 'Tag "%s" (%s): %d posts updated',
7326 - $tag_slug,
7327 - $role_restriction,
7328 - $tag_updated
7329 - );
7330 - }
7331 - }
7332 -
7333 - wp_send_json_success(array(
7334 - 'message' => 'Bulk update completed',
7335 - 'updated_count' => $updated_count,
7336 - 'tags_processed' => count($mappings),
7337 - 'details' => $details
7338 - ));
7339 - exit;
7340 -}
7341 -
7342 -/**
7343 - * Handle tag changes on posts (when tags are added or removed)
7344 - */
7345 -public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
7346 - // Only process post tags
7347 - if ($taxonomy !== 'post_tag') {
7348 - return;
7349 - }
7350 -
7351 - // Get tag-role mappings
7352 - $mappings = get_option('mxchat_tag_role_mappings', array());
7353 -
7354 - if (empty($mappings)) {
7355 - return;
7356 - }
7357 -
7358 - // Get the post's URL
7359 - $source_url = get_permalink($object_id);
7360 - if (!$source_url) {
7361 - return;
7362 - }
7363 -
7364 - // Determine the highest role restriction based on tags
7365 - $highest_role = 'public';
7366 - $role_hierarchy = array(
7367 - 'public' => 0,
7368 - 'logged_in' => 1,
7369 - 'subscriber' => 2,
7370 - 'contributor' => 3,
7371 - 'author' => 4,
7372 - 'editor' => 5,
7373 - 'administrator' => 6
7374 - );
7375 -
7376 - // Get all current tags for the post
7377 - $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
7378 -
7379 - // Find the highest role restriction among the tags
7380 - foreach ($current_tags as $tag_slug) {
7381 - if (isset($mappings[$tag_slug])) {
7382 - $role = $mappings[$tag_slug];
7383 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
7384 - $highest_role = $role;
7385 - }
7386 - }
7387 - }
7388 -
7389 - // Update the role restriction in the database
7390 - global $wpdb;
7391 -
7392 - // Check if using Pinecone
7393 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7394 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7395 -
7396 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7397 - // Update Pinecone role restriction
7398 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7399 - $vector_id = md5($source_url);
7400 -
7401 - $wpdb->replace(
7402 - $roles_table,
7403 - array(
7404 - 'vector_id' => $vector_id,
7405 - 'role_restriction' => $highest_role,
7406 - 'updated_at' => current_time('mysql')
7407 - ),
7408 - array('%s', '%s', '%s')
7409 - );
7410 - } else {
7411 - // Update WordPress DB
7412 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7413 -
7414 - $wpdb->update(
7415 - $table_name,
7416 - array('role_restriction' => $highest_role),
7417 - array('source_url' => $source_url),
7418 - array('%s'),
7419 - array('%s')
7420 - );
7421 - }
7422 -}
7423 -
7424 -/**
7425 - * Apply role restriction after content is stored (for auto-sync)
7426 - */
7427 -public function apply_role_restriction_after_storage($post_id, $source_url) {
7428 - // Get tag-role mappings
7429 - $mappings = get_option('mxchat_tag_role_mappings', array());
7430 -
7431 - if (empty($mappings)) {
7432 - return;
7433 - }
7434 -
7435 - // Get all tags for the post
7436 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
7437 -
7438 - if (empty($post_tags)) {
7439 - return;
7440 - }
7441 -
7442 - // Determine the highest role restriction based on tags
7443 - $highest_role = 'public';
7444 - $role_hierarchy = array(
7445 - 'public' => 0,
7446 - 'logged_in' => 1,
7447 - 'subscriber' => 2,
7448 - 'contributor' => 3,
7449 - 'author' => 4,
7450 - 'editor' => 5,
7451 - 'administrator' => 6
7452 - );
7453 -
7454 - foreach ($post_tags as $tag_slug) {
7455 - if (isset($mappings[$tag_slug])) {
7456 - $role = $mappings[$tag_slug];
7457 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
7458 - $highest_role = $role;
7459 - }
7460 - }
7461 - }
7462 -
7463 - // If no restricted tags found, return (leave as public)
7464 - if ($highest_role === 'public') {
7465 - return;
7466 - }
7467 -
7468 - // Update the role restriction
7469 - global $wpdb;
7470 -
7471 - // Check if using Pinecone
7472 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7473 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7474 -
7475 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7476 - // Update Pinecone role restriction
7477 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7478 - $vector_id = md5($source_url);
7479 -
7480 - $wpdb->replace(
7481 - $roles_table,
7482 - array(
7483 - 'vector_id' => $vector_id,
7484 - 'role_restriction' => $highest_role,
7485 - 'updated_at' => current_time('mysql')
7486 - ),
7487 - array('%s', '%s', '%s')
7488 - );
7489 - } else {
7490 - // Update WordPress DB
7491 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7492 -
7493 - $wpdb->update(
7494 - $table_name,
7495 - array('role_restriction' => $highest_role),
7496 - array('source_url' => $source_url),
7497 - array('%s'),
7498 - array('%s')
7499 - );
7500 - }
7501 -}
7502 -
7503 -
7504 - // ========================================
7505 - // HELPER METHODS
7506 - // ========================================
7507 -
7508 - /**
7509 - * Check if user has required permissions for content processing
7510 - */
7511 - private function mxchat_check_user_permissions() {
7512 - if (!current_user_can('manage_options')) {
7513 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
7514 - }
7515 - }
7516 -
7517 - /**
7518 - * Validate nonce for security
7519 - */
7520 - private function mxchat_validate_nonce($nonce_name, $nonce_action) {
7521 - if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
7522 - wp_die(esc_html__('Security check failed.', 'mxchat'));
7523 - }
7524 - }
7525 -
7526 - /**
7527 - * Get embedding API credentials
7528 - */
7529 - private function mxchat_get_embedding_credentials() {
7530 - $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
7531 -
7532 - if (strpos($embedding_model, 'text-embedding-') !== false) {
7533 - return array(
7534 - 'type' => 'openai',
7535 - 'api_key' => $this->options['api_key'] ?? ''
7536 - );
7537 - } elseif (strpos($embedding_model, 'voyage-') !== false) {
7538 - return array(
7539 - 'type' => 'voyage',
7540 - 'api_key' => $this->options['voyage_api_key'] ?? ''
7541 - );
7542 - } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
7543 - return array(
7544 - 'type' => 'gemini',
7545 - 'api_key' => $this->options['gemini_api_key'] ?? ''
7546 - );
7547 - }
7548 -
7549 - return array('type' => 'unknown', 'api_key' => '');
7550 - }
7551 -
7552 - /**
7553 - * Log processing errors
7554 - */
7555 - private function mxchat_log_processing_error($operation, $error_message) {
7556 - //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
7557 - }
7558 -
7559 - /**
7560 - * Set admin notice transient
7561 - */
7562 - private function mxchat_set_admin_notice($type, $message) {
7563 - set_transient("mxchat_admin_notice_{$type}", $message, 30);
7564 - }
7565 -
7566 - /**
7567 - * Get Pinecone manager instance for vector operations
7568 - */
7569 - private function mxchat_get_pinecone_manager() {
7570 - return MxChat_Pinecone_Manager::get_instance();
7571 - }
7572 -
7573 -
7574 - // ========================================
7575 -// DATABASE QUEUE TABLE MANAGEMENT
7576 -// ========================================
7577 -
7578 -/**
7579 - * Create queue table on plugin activation
7580 - * Call this from your plugin activation hook
7581 - */
7582 -public function mxchat_create_queue_table() {
7583 - global $wpdb;
7584 -
7585 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7586 - $charset_collate = $wpdb->get_charset_collate();
7587 -
7588 - $sql = "CREATE TABLE IF NOT EXISTS $table_name (
7589 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
7590 - queue_id varchar(64) NOT NULL,
7591 - item_type varchar(20) NOT NULL,
7592 - item_data longtext NOT NULL,
7593 - status varchar(20) NOT NULL DEFAULT 'pending',
7594 - bot_id varchar(50) NOT NULL DEFAULT 'default',
7595 - priority int(11) NOT NULL DEFAULT 0,
7596 - attempts int(11) NOT NULL DEFAULT 0,
7597 - max_attempts int(11) NOT NULL DEFAULT 3,
7598 - error_message text DEFAULT NULL,
7599 - created_at datetime NOT NULL,
7600 - started_at datetime DEFAULT NULL,
7601 - completed_at datetime DEFAULT NULL,
7602 - PRIMARY KEY (id),
7603 - KEY queue_id (queue_id),
7604 - KEY status (status),
7605 - KEY item_type (item_type),
7606 - KEY priority (priority)
7607 - ) $charset_collate;";
7608 -
7609 - require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
7610 - dbDelta($sql);
7611 -
7612 - // Also create a meta table for queue metadata
7613 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7614 -
7615 - $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
7616 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
7617 - queue_id varchar(64) NOT NULL,
7618 - meta_key varchar(255) NOT NULL,
7619 - meta_value longtext,
7620 - PRIMARY KEY (id),
7621 - KEY queue_id (queue_id),
7622 - KEY meta_key (meta_key)
7623 - ) $charset_collate;";
7624 -
7625 - dbDelta($meta_sql);
7626 -}
7627 -
7628 -/**
7629 - * Add items to the processing queue
7630 - *
7631 - * @param string $queue_id Unique identifier for this queue batch
7632 - * @param string $item_type Type of item (url, pdf_page)
7633 - * @param array $items Array of items to queue
7634 - * @param string $bot_id Bot ID for processing
7635 - * @return int Number of items queued
7636 - */
7637 -private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
7638 - global $wpdb;
7639 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7640 -
7641 - $queued_count = 0;
7642 - $priority = 0;
7643 -
7644 - foreach ($items as $item) {
7645 - $result = $wpdb->insert(
7646 - $table_name,
7647 - array(
7648 - 'queue_id' => $queue_id,
7649 - 'item_type' => $item_type,
7650 - 'item_data' => wp_json_encode($item),
7651 - 'status' => 'pending',
7652 - 'bot_id' => $bot_id,
7653 - 'priority' => $priority,
7654 - 'attempts' => 0,
7655 - 'max_attempts' => 3,
7656 - 'created_at' => current_time('mysql')
7657 - ),
7658 - array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
7659 - );
7660 -
7661 - if ($result) {
7662 - $queued_count++;
7663 - }
7664 -
7665 - $priority++; // Process in order
7666 - }
7667 -
7668 - return $queued_count;
7669 -}
7670 -
7671 -/**
7672 - * Store queue metadata (total counts, source URL, etc.)
7673 - */
7674 -private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
7675 - global $wpdb;
7676 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7677 -
7678 - // Check if meta exists
7679 - $existing = $wpdb->get_var($wpdb->prepare(
7680 - "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
7681 - $queue_id,
7682 - $meta_key
7683 - ));
7684 -
7685 - if ($existing) {
7686 - // Update
7687 - $wpdb->update(
7688 - $meta_table,
7689 - array('meta_value' => maybe_serialize($meta_value)),
7690 - array('queue_id' => $queue_id, 'meta_key' => $meta_key),
7691 - array('%s'),
7692 - array('%s', '%s')
7693 - );
7694 - } else {
7695 - // Insert
7696 - $wpdb->insert(
7697 - $meta_table,
7698 - array(
7699 - 'queue_id' => $queue_id,
7700 - 'meta_key' => $meta_key,
7701 - 'meta_value' => maybe_serialize($meta_value)
7702 - ),
7703 - array('%s', '%s', '%s')
7704 - );
7705 - }
7706 -}
7707 -
7708 -/**
7709 - * Get queue metadata
7710 - */
7711 -private function mxchat_get_queue_meta($queue_id, $meta_key) {
7712 - global $wpdb;
7713 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7714 -
7715 - $value = $wpdb->get_var($wpdb->prepare(
7716 - "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
7717 - $queue_id,
7718 - $meta_key
7719 - ));
7720 -
7721 - return maybe_unserialize($value);
7722 -}
7723 -
7724 -// ========================================
7725 -// AJAX QUEUE PROCESSING HANDLERS
7726 -// ========================================
7727 -
7728 -/**
7729 - * AJAX: Get next item from queue to process
7730 - */
7731 -public function ajax_mxchat_get_next_queue_item() {
7732 - // Verify nonce and permissions
7733 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7734 -
7735 - if (!current_user_can('manage_options')) {
7736 - wp_send_json_error('Unauthorized access');
7737 - }
7738 -
7739 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7740 -
7741 - if (empty($queue_id)) {
7742 - wp_send_json_error('Missing queue ID');
7743 - }
7744 -
7745 - global $wpdb;
7746 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7747 -
7748 - // Get next pending item with retry logic for failed items
7749 - $next_item = $wpdb->get_row($wpdb->prepare(
7750 - "SELECT * FROM $table_name
7751 - WHERE queue_id = %s
7752 - AND status IN ('pending', 'failed')
7753 - AND attempts < max_attempts
7754 - ORDER BY priority ASC, id ASC
7755 - LIMIT 1",
7756 - $queue_id
7757 - ));
7758 -
7759 - if (!$next_item) {
7760 - // No more items - queue complete
7761 - wp_send_json_success(array(
7762 - 'complete' => true,
7763 - 'message' => 'Queue processing complete'
7764 - ));
7765 - }
7766 -
7767 - // Mark item as processing
7768 - $wpdb->update(
7769 - $table_name,
7770 - array(
7771 - 'status' => 'processing',
7772 - 'started_at' => current_time('mysql'),
7773 - 'attempts' => $next_item->attempts + 1
7774 - ),
7775 - array('id' => $next_item->id),
7776 - array('%s', '%s', '%d'),
7777 - array('%d')
7778 - );
7779 -
7780 - wp_send_json_success(array(
7781 - 'complete' => false,
7782 - 'item' => array(
7783 - 'id' => $next_item->id,
7784 - 'type' => $next_item->item_type,
7785 - 'data' => json_decode($next_item->item_data, true),
7786 - 'bot_id' => $next_item->bot_id,
7787 - 'attempt' => $next_item->attempts + 1
7788 - )
7789 - ));
7790 -}
7791 -
7792 -/**
7793 - * AJAX: Process a single queue item
7794 - */
7795 -public function ajax_mxchat_process_queue_item() {
7796 - // Verify nonce and permissions
7797 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
7798 -
7799 - if (!current_user_can('manage_options')) {
7800 - wp_send_json_error('Unauthorized access');
7801 - }
7802 -
7803 - $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
7804 - $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
7805 - $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
7806 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
7807 -
7808 - if (empty($item_id) || empty($item_type)) {
7809 - wp_send_json_error('Missing item data');
7810 - }
7811 -
7812 - global $wpdb;
7813 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7814 -
7815 - // Process based on item type
7816 - try {
7817 - set_time_limit(60); // Give processing 60 seconds
7818 -
7819 - $result = false;
7820 - $error_message = '';
7821 -
7822 - // Read item directly from DB to get queue_id and preserve special chars in item_data
7823 - // (POST round-trip through JS mangles characters like apostrophes in URLs)
7824 - $db_item = $wpdb->get_row($wpdb->prepare(
7825 - "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
7826 - $item_id
7827 - ));
7828 - $item_queue_id = $db_item ? $db_item->queue_id : '';
7829 - if ($db_item && !empty($db_item->item_data)) {
7830 - $db_data = json_decode($db_item->item_data, true);
7831 - if (is_array($db_data)) {
7832 - $item_data = $db_data;
7833 - }
7834 - }
7835 -
7836 - switch ($item_type) {
7837 - case 'url':
7838 - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
7839 - break;
7840 -
7841 - case 'pdf_page':
7842 - $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
7843 - break;
7844 -
7845 - default:
7846 - throw new Exception('Unknown item type: ' . $item_type);
7847 - }
7848 -
7849 - if (is_wp_error($result)) {
7850 - $error_code = $result->get_error_code();
7851 - // Content errors (empty page, sanitization) are permanent — retrying won't help
7852 - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
7853 - if (in_array($error_code, $permanent_codes)) {
7854 - // Mark as permanently failed — set attempts = max_attempts so it won't be retried
7855 - $current_item = $wpdb->get_row($wpdb->prepare(
7856 - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
7857 - ));
7858 - $wpdb->update(
7859 - $table_name,
7860 - array(
7861 - 'status' => 'failed',
7862 - 'error_message' => $result->get_error_message(),
7863 - 'attempts' => $current_item ? $current_item->max_attempts : 3
7864 - ),
7865 - array('id' => $item_id),
7866 - array('%s', '%s', '%d'),
7867 - array('%d')
7868 - );
7869 - wp_send_json_error(array(
7870 - 'message' => $result->get_error_message(),
7871 - 'permanent_failure' => true,
7872 - 'item_id' => $item_id
7873 - ));
7874 - return;
7875 - }
7876 - throw new Exception($result->get_error_message());
7877 - }
7878 -
7879 - if ($result === false) {
7880 - throw new Exception('Processing returned false - item may be empty or invalid');
7881 - }
7882 -
7883 - // Mark as completed
7884 - $wpdb->update(
7885 - $table_name,
7886 - array(
7887 - 'status' => 'completed',
7888 - 'completed_at' => current_time('mysql'),
7889 - 'error_message' => null
7890 - ),
7891 - array('id' => $item_id),
7892 - array('%s', '%s', '%s'),
7893 - array('%d')
7894 - );
7895 -
7896 - wp_send_json_success(array(
7897 - 'processed' => true,
7898 - 'item_id' => $item_id,
7899 - 'message' => 'Item processed successfully'
7900 - ));
7901 -
7902 - } catch (Exception $e) {
7903 - $error_message = $e->getMessage();
7904 -
7905 - // Get current attempt count
7906 - $item = $wpdb->get_row($wpdb->prepare(
7907 - "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
7908 - $item_id
7909 - ));
7910 -
7911 - // Check if we've exhausted retries
7912 - if ($item && $item->attempts >= $item->max_attempts) {
7913 - // Permanently failed
7914 - $wpdb->update(
7915 - $table_name,
7916 - array(
7917 - 'status' => 'failed',
7918 - 'error_message' => $error_message
7919 - ),
7920 - array('id' => $item_id),
7921 - array('%s', '%s'),
7922 - array('%d')
7923 - );
7924 -
7925 - wp_send_json_error(array(
7926 - 'message' => 'Item failed after maximum attempts: ' . $error_message,
7927 - 'permanent_failure' => true,
7928 - 'item_id' => $item_id
7929 - ));
7930 - } else {
7931 - // Mark for retry
7932 - $wpdb->update(
7933 - $table_name,
7934 - array(
7935 - 'status' => 'failed',
7936 - 'error_message' => $error_message
7937 - ),
7938 - array('id' => $item_id),
7939 - array('%s', '%s'),
7940 - array('%d')
7941 - );
7942 -
7943 - wp_send_json_error(array(
7944 - 'message' => 'Item processing failed, will retry: ' . $error_message,
7945 - 'can_retry' => true,
7946 - 'item_id' => $item_id,
7947 - 'attempts' => $item ? $item->attempts : 0
7948 - ));
7949 - }
7950 - }
7951 -}
7952 -
7953 -/**
7954 - * Process a URL from the queue
7955 - */
7956 -private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
7957 - $url = isset($item_data['url']) ? $item_data['url'] : '';
7958 -
7959 - if (empty($url)) {
7960 - return new WP_Error('invalid_url', 'URL is empty');
7961 - }
7962 -
7963 - // Get bot-specific API key early (needed for both paths)
7964 - $bot_options = $this->get_bot_options($bot_id);
7965 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7966 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7967 -
7968 - if (strpos($selected_model, 'voyage') === 0) {
7969 - $api_key = $options['voyage_api_key'] ?? '';
7970 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7971 - $api_key = $options['gemini_api_key'] ?? '';
7972 - } else {
7973 - $api_key = $options['api_key'] ?? '';
7974 - }
7975 -
7976 - if (empty($api_key)) {
7977 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7978 - }
7979 -
7980 - // Check if this is a WooCommerce product URL and WooCommerce is active
7981 - $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
7982 - $content_type = $is_product_url ? 'product' : 'url';
7983 -
7984 - // Try to get WooCommerce product data if it's a product URL
7985 - if ($is_product_url && class_exists('WooCommerce')) {
7986 - $product_content = $this->mxchat_extract_woocommerce_product_content($url);
7987 -
7988 - if (!empty($product_content)) {
7989 - // Successfully extracted WooCommerce product data with pricing
7990 - $result = MxChat_Utils::submit_content_to_db(
7991 - $product_content,
7992 - $url,
7993 - $api_key,
7994 - null,
7995 - $bot_id,
7996 - 'product'
7997 - );
7998 - return $result;
7999 - }
8000 - // If WooCommerce extraction failed, fall through to HTML extraction
8001 - }
8002 -
8003 - // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
8004 - $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
8005 - $response = wp_remote_get($url, array(
8006 - 'timeout' => $is_likely_pdf ? 120 : 30,
8007 - 'redirection' => 5,
8008 - 'user-agent' => mxchat_ingest_user_agent(),
8009 - ));
8010 -
8011 - if (is_wp_error($response)) {
8012 - return $response;
8013 - }
8014 -
8015 - $response_code = wp_remote_retrieve_response_code($response);
8016 - if ($response_code !== 200) {
8017 - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
8018 - }
8019 -
8020 - // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
8021 - if ($this->mxchat_is_pdf_url($url, $response)) {
8022 - return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
8023 - }
8024 -
8025 - $html = wp_remote_retrieve_body($response);
8026 -
8027 - if (empty($html)) {
8028 - return new WP_Error('empty_response', 'Empty response body');
8029 - }
8030 -
8031 - // Extract and sanitize content
8032 - $content = $this->mxchat_extract_main_content($html);
8033 - $sanitized = $this->mxchat_sanitize_content_for_api($content);
8034 -
8035 - if (empty($sanitized)) {
8036 - // Not an error - just no content found (maybe a redirect or empty page)
8037 - return false;
8038 - }
8039 -
8040 - // Submit to database with content_type
8041 - $result = MxChat_Utils::submit_content_to_db(
8042 - $sanitized,
8043 - $url,
8044 - $api_key,
8045 - null,
8046 - $bot_id,
8047 - $content_type
8048 - );
8049 -
8050 - return $result;
8051 -}
8052 -
8053 -/**
8054 - * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
8055 - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
8056 - * and adds pdf_page items to the same queue so they process with full progress tracking.
8057 - */
8058 -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
8059 - set_time_limit(120); // PDFs need extra time for download + parsing
8060 -
8061 - $upload_dir = wp_upload_dir();
8062 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
8063 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
8064 -
8065 - $response_body = wp_remote_retrieve_body($response);
8066 - if (empty($response_body)) {
8067 - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
8068 - }
8069 -
8070 - if (!wp_mkdir_p(dirname($pdf_path))) {
8071 - return new WP_Error('dir_error', 'Failed to create upload directory');
8072 - }
8073 -
8074 - file_put_contents($pdf_path, $response_body);
8075 -
8076 - if (!file_exists($pdf_path)) {
8077 - return new WP_Error('save_error', 'Failed to save PDF file');
8078 - }
8079 -
8080 - try {
8081 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
8082 -
8083 - if ($total_pages === false || $total_pages < 1) {
8084 - wp_delete_file($pdf_path);
8085 - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
8086 - }
8087 -
8088 - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
8089 - $pages = array();
8090 - for ($i = 1; $i <= $total_pages; $i++) {
8091 - $pages[] = array(
8092 - 'pdf_path' => $pdf_path,
8093 - 'pdf_url' => $pdf_url,
8094 - 'page_number' => $i,
8095 - 'total_pages' => $total_pages
8096 - );
8097 - }
8098 -
8099 - // Add pdf_page items to the SAME queue so the JS picks them up automatically
8100 - if (!empty($queue_id)) {
8101 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
8102 - } else {
8103 - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
8104 - $new_queue_id = 'pdf_' . md5($pdf_url . time());
8105 - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
8106 - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
8107 - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
8108 - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
8109 - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
8110 - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
8111 - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
8112 - }
8113 -
8114 - if ($queued_count === 0) {
8115 - wp_delete_file($pdf_path);
8116 - return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
8117 - }
8118 -
8119 - // Return true so the original URL item is marked complete
8120 - // The new pdf_page items will be processed in subsequent batches
8121 - return true;
8122 -
8123 - } catch (Exception $e) {
8124 - if (file_exists($pdf_path)) {
8125 - wp_delete_file($pdf_path);
8126 - }
8127 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
8128 - }
8129 -}
8130 -
8131 -/**
8132 - * Legacy: Process a PDF URL inline during sitemap queue processing.
8133 - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
8134 - */
8135 -private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
8136 - set_time_limit(120); // PDFs need more time — downloading + parsing all pages
8137 -
8138 - $upload_dir = wp_upload_dir();
8139 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
8140 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
8141 -
8142 - $response_body = wp_remote_retrieve_body($response);
8143 - if (empty($response_body)) {
8144 - return new WP_Error('empty_pdf', 'Empty PDF response');
8145 - }
8146 -
8147 - if (!wp_mkdir_p(dirname($pdf_path))) {
8148 - return new WP_Error('dir_error', 'Failed to create upload directory');
8149 - }
8150 -
8151 - file_put_contents($pdf_path, $response_body);
8152 -
8153 - if (!file_exists($pdf_path)) {
8154 - return new WP_Error('save_error', 'Failed to save PDF file');
8155 - }
8156 -
8157 - try {
8158 - mxchat_load_pdf_parser();
8159 - $parser = new \Smalot\PdfParser\Parser();
8160 - $pdf = $parser->parseFile($pdf_path);
8161 - $pages = $pdf->getPages();
8162 - $total_pages = count($pages);
8163 -
8164 - if ($total_pages < 1) {
8165 - wp_delete_file($pdf_path);
8166 - return new WP_Error('no_pages', 'PDF has no pages');
8167 - }
8168 -
8169 - $processed = 0;
8170 - $skipped_pages = array();
8171 -
8172 - for ($i = 0; $i < $total_pages; $i++) {
8173 - $page_num = $i + 1;
8174 - $text = $pages[$i]->getText();
8175 - if (empty($text)) {
8176 - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
8177 - continue;
8178 - }
8179 -
8180 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
8181 - if (empty($sanitized)) {
8182 - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
8183 - continue;
8184 - }
8185 -
8186 - $metadata = array(
8187 - 'document_type' => 'pdf',
8188 - 'total_pages' => $total_pages,
8189 - 'current_page' => $page_num,
8190 - 'source_url' => $pdf_url,
8191 - );
8192 -
8193 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
8194 - $page_url = esc_url($pdf_url . '#page=' . $page_num);
8195 -
8196 - MxChat_Utils::submit_content_to_db(
8197 - $content_with_metadata,
8198 - $page_url,
8199 - $api_key,
8200 - null,
8201 - $bot_id,
8202 - 'pdf'
8203 - );
8204 -
8205 - $processed++;
8206 - }
8207 -
8208 - // Clean up the temp PDF file
8209 - wp_delete_file($pdf_path);
8210 -
8211 - if (!empty($skipped_pages)) {
8212 - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
8213 - }
8214 -
8215 - return $processed > 0 ? true : false;
8216 -
8217 - } catch (Exception $e) {
8218 - if (file_exists($pdf_path)) {
8219 - wp_delete_file($pdf_path);
8220 - }
8221 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
8222 - }
8223 -}
8224 -
8225 -/**
8226 - * Extract WooCommerce product content including pricing
8227 - *
8228 - * @param string $url The product URL
8229 - * @return string|false Product content with pricing, or false if not found
8230 - */
8231 -private function mxchat_extract_woocommerce_product_content($url) {
8232 - // Try to get product ID from URL
8233 - $product_id = url_to_postid($url);
8234 -
8235 - // If url_to_postid fails, try to extract from URL pattern
8236 - if (!$product_id) {
8237 - $product_slug = '';
8238 -
8239 - // Handle pretty permalinks: /product/product-name/
8240 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
8241 - $product_slug = $matches[1];
8242 - }
8243 -
8244 - if (!empty($product_slug)) {
8245 - $product_post = get_page_by_path($product_slug, OBJECT, 'product');
8246 - if ($product_post) {
8247 - $product_id = $product_post->ID;
8248 - }
8249 - }
8250 - }
8251 -
8252 - if (!$product_id) {
8253 - return false;
8254 - }
8255 -
8256 - // Get WooCommerce product object
8257 - $product = wc_get_product($product_id);
8258 -
8259 - if (!$product) {
8260 - return false;
8261 - }
8262 -
8263 - // Build product content with pricing (similar to mxchat_store_product_embedding)
8264 - $title = $product->get_name();
8265 - $description = $product->get_description();
8266 - $short_description = $product->get_short_description();
8267 - $sku = $product->get_sku();
8268 -
8269 - // Get pricing information
8270 - $regular_price = $product->get_regular_price();
8271 - $sale_price = $product->get_sale_price();
8272 - $price = $product->get_price(); // Current active price
8273 -
8274 - // Get currency symbol
8275 - $currency_symbol = get_woocommerce_currency_symbol();
8276 -
8277 - // Format content
8278 - $content = $title . "\n\n";
8279 -
8280 - if (!empty($short_description)) {
8281 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
8282 - }
8283 -
8284 - if (!empty($description)) {
8285 - $content .= wp_strip_all_tags($description) . "\n\n";
8286 - }
8287 -
8288 - // Add pricing information
8289 - if (!empty($regular_price)) {
8290 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
8291 - } elseif (!empty($price)) {
8292 - $content .= "Price: " . $currency_symbol . $price . "\n";
8293 - }
8294 -
8295 - if (!empty($sale_price) && $sale_price !== $regular_price) {
8296 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
8297 - }
8298 -
8299 - // Handle variable products - show price range
8300 - if ($product->is_type('variable')) {
8301 - $min_price = $product->get_variation_price('min');
8302 - $max_price = $product->get_variation_price('max');
8303 - if ($min_price !== $max_price) {
8304 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
8305 - }
8306 - }
8307 -
8308 - if (!empty($sku)) {
8309 - $content .= "SKU: " . $sku . "\n";
8310 - }
8311 -
8312 - // Get product categories
8313 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
8314 - if (!empty($categories) && !is_wp_error($categories)) {
8315 - $content .= "Categories: " . implode(', ', $categories) . "\n";
8316 - }
8317 -
8318 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
8319 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
8320 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
8321 - foreach ($custom_tabs as $tab) {
8322 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
8323 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
8324 -
8325 - if (!empty($tab_title) && !empty($tab_content)) {
8326 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
8327 - }
8328 - }
8329 - }
8330 -
8331 - // Also check for reusable/saved tabs applied to this product
8332 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
8333 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
8334 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
8335 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
8336 - foreach ($applied_saved_tabs as $saved_tab_id) {
8337 - if (isset($saved_tabs[$saved_tab_id])) {
8338 - $tab = $saved_tabs[$saved_tab_id];
8339 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
8340 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
8341 -
8342 - if (!empty($tab_title) && !empty($tab_content)) {
8343 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
8344 - }
8345 - }
8346 - }
8347 - }
8348 - }
8349 -
8350 - return $this->mxchat_sanitize_content_for_api($content);
8351 -}
8352 -
8353 -/**
8354 - * Process a PDF page from the queue
8355 - */
8356 -private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
8357 - $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
8358 - $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
8359 - $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
8360 - $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
8361 -
8362 - if (empty($pdf_path) || !file_exists($pdf_path)) {
8363 - return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
8364 - }
8365 -
8366 - if ($page_number < 1) {
8367 - return new WP_Error('invalid_page', 'Invalid page number');
8368 - }
8369 -
8370 - try {
8371 - mxchat_load_pdf_parser();
8372 - $parser = new \Smalot\PdfParser\Parser();
8373 - $pdf = $parser->parseFile($pdf_path);
8374 - $pages = $pdf->getPages();
8375 -
8376 - if (!isset($pages[$page_number - 1])) {
8377 - return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
8378 - }
8379 -
8380 - $text = $pages[$page_number - 1]->getText();
8381 -
8382 - if (empty($text)) {
8383 - return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
8384 - }
8385 -
8386 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
8387 -
8388 - if (empty($sanitized)) {
8389 - 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');
8390 - }
8391 -
8392 - // Create metadata
8393 - $metadata = array(
8394 - 'document_type' => 'pdf',
8395 - 'total_pages' => $total_pages,
8396 - 'current_page' => $page_number,
8397 - 'source_url' => $pdf_url
8398 - );
8399 -
8400 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
8401 - $page_url = esc_url($pdf_url . "#page=" . $page_number);
8402 -
8403 - // Get bot-specific API key
8404 - $bot_options = $this->get_bot_options($bot_id);
8405 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
8406 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
8407 -
8408 - if (strpos($selected_model, 'voyage') === 0) {
8409 - $api_key = $options['voyage_api_key'] ?? '';
8410 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
8411 - $api_key = $options['gemini_api_key'] ?? '';
8412 - } else {
8413 - $api_key = $options['api_key'] ?? '';
8414 - }
8415 -
8416 - if (empty($api_key)) {
8417 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
8418 - }
8419 -
8420 - // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
8421 - $result = MxChat_Utils::submit_content_to_db(
8422 - $content_with_metadata,
8423 - $page_url,
8424 - $api_key,
8425 - null,
8426 - $bot_id,
8427 - 'pdf'
8428 - );
8429 -
8430 - return $result;
8431 -
8432 - } catch (Exception $e) {
8433 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
8434 - }
8435 -}
8436 -
8437 -/**
8438 - * AJAX: Get queue processing status
8439 - */
8440 -public function ajax_mxchat_get_queue_status() {
8441 - // Verify nonce and permissions
8442 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8443 -
8444 - if (!current_user_can('manage_options')) {
8445 - wp_send_json_error('Unauthorized access');
8446 - }
8447 -
8448 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8449 -
8450 - if (empty($queue_id)) {
8451 - wp_send_json_error('Missing queue ID');
8452 - }
8453 -
8454 - global $wpdb;
8455 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8456 -
8457 - // Get counts by status
8458 - $counts = $wpdb->get_results($wpdb->prepare(
8459 - "SELECT status, COUNT(*) as count
8460 - FROM $table_name
8461 - WHERE queue_id = %s
8462 - GROUP BY status",
8463 - $queue_id
8464 - ), OBJECT_K);
8465 -
8466 - $total = 0;
8467 - $completed = 0;
8468 - $failed = 0;
8469 - $processing = 0;
8470 - $pending = 0;
8471 -
8472 - foreach ($counts as $status => $data) {
8473 - $count = absint($data->count);
8474 - $total += $count;
8475 -
8476 - switch ($status) {
8477 - case 'completed':
8478 - $completed = $count;
8479 - break;
8480 - case 'failed':
8481 - $failed = $count;
8482 - break;
8483 - case 'processing':
8484 - $processing = $count;
8485 - break;
8486 - case 'pending':
8487 - $pending = $count;
8488 - break;
8489 - }
8490 - }
8491 -
8492 - // Calculate percentage
8493 - $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
8494 -
8495 - // Get failed items details (include all failed items, not just those that exhausted retries)
8496 - $failed_items = array();
8497 - if ($failed > 0) {
8498 - $failed_items = $wpdb->get_results($wpdb->prepare(
8499 - "SELECT item_type, item_data, error_message, attempts
8500 - FROM $table_name
8501 - WHERE queue_id = %s
8502 - AND status = 'failed'
8503 - ORDER BY id DESC
8504 - LIMIT 50",
8505 - $queue_id
8506 - ));
8507 - }
8508 -
8509 - // Get queue metadata
8510 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
8511 - $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
8512 -
8513 - // Determine if queue is complete
8514 - $is_complete = ($pending === 0 && $processing === 0);
8515 -
8516 - wp_send_json_success(array(
8517 - 'queue_id' => $queue_id,
8518 - 'queue_type' => $queue_type,
8519 - 'source_url' => $source_url,
8520 - 'total' => $total,
8521 - 'completed' => $completed,
8522 - 'failed' => $failed,
8523 - 'processing' => $processing,
8524 - 'pending' => $pending,
8525 - 'percentage' => $percentage,
8526 - 'is_complete' => $is_complete,
8527 - 'failed_items' => $failed_items,
8528 - 'status' => $is_complete ? 'complete' : 'processing'
8529 - ));
8530 -}
8531 -
8532 -/**
8533 - * AJAX: Clear completed queue
8534 - */
8535 -public function ajax_mxchat_clear_queue() {
8536 - // Verify nonce and permissions
8537 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8538 -
8539 - if (!current_user_can('manage_options')) {
8540 - wp_send_json_error('Unauthorized access');
8541 - }
8542 -
8543 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8544 -
8545 - if (empty($queue_id)) {
8546 - wp_send_json_error('Missing queue ID');
8547 - }
8548 -
8549 - global $wpdb;
8550 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8551 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
8552 -
8553 - // Delete queue items
8554 - $wpdb->delete(
8555 - $table_name,
8556 - array('queue_id' => $queue_id),
8557 - array('%s')
8558 - );
8559 -
8560 - // Delete queue metadata
8561 - $wpdb->delete(
8562 - $meta_table,
8563 - array('queue_id' => $queue_id),
8564 - array('%s')
8565 - );
8566 -
8567 - wp_send_json_success(array(
8568 - 'message' => 'Queue cleared successfully'
8569 - ));
8570 -}
8571 -
8572 -/**
8573 - * AJAX: Retry failed items in queue
8574 - */
8575 -public function ajax_mxchat_retry_failed() {
8576 - // Verify nonce and permissions
8577 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8578 -
8579 - if (!current_user_can('manage_options')) {
8580 - wp_send_json_error('Unauthorized access');
8581 - }
8582 -
8583 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8584 -
8585 - if (empty($queue_id)) {
8586 - wp_send_json_error('Missing queue ID');
8587 - }
8588 -
8589 - global $wpdb;
8590 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8591 -
8592 - // Reset failed items to pending and reset attempt count
8593 - $updated = $wpdb->update(
8594 - $table_name,
8595 - array(
8596 - 'status' => 'pending',
8597 - 'attempts' => 0,
8598 - 'error_message' => null
8599 - ),
8600 - array(
8601 - 'queue_id' => $queue_id,
8602 - 'status' => 'failed'
8603 - ),
8604 - array('%s', '%d', '%s'),
8605 - array('%s', '%s')
8606 - );
8607 -
8608 - wp_send_json_success(array(
8609 - 'message' => 'Reset ' . $updated . ' failed items for retry',
8610 - 'reset_count' => $updated
8611 - ));
8612 -}
8613 -
8614 -
8615 -public function ajax_mxchat_mark_queue_complete() {
8616 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8617 -
8618 - if (!current_user_can('manage_options')) {
8619 - wp_send_json_error('Unauthorized access');
8620 - }
8621 -
8622 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8623 -
8624 - if (empty($queue_id)) {
8625 - wp_send_json_error('Missing queue ID');
8626 - }
8627 -
8628 - // Clear active queue transients
8629 - if (strpos($queue_id, 'sitemap_') === 0) {
8630 - delete_transient('mxchat_active_queue_sitemap');
8631 - } else if (strpos($queue_id, 'pdf_') === 0) {
8632 - delete_transient('mxchat_active_queue_pdf');
8633 - }
8634 -
8635 - wp_send_json_success(array('message' => 'Queue marked as complete'));
8636 -}
8637 -
8638 -
8639 - // ========================================
8640 - // STATIC ACCESS METHODS
8641 - // ========================================
8642 -
8643 - /**
8644 - * Get singleton instance
8645 - */
8646 - public static function get_instance() {
8647 - static $instance = null;
8648 - if ($instance === null) {
8649 - $instance = new self();
8650 - }
8651 - return $instance;
8652 - }
8653 -}
8654 -
8655 -// Initialize the Knowledge manager
1 +<?php
2 +/**
3 + * File: admin/class-knowledge-manager.php
4 + *
5 + * Handles all knowledge base content processing for MxChat
6 + * Including PDF, sitemap, content processing, and WordPress post management
7 + */
8 +if (!defined('ABSPATH')) {
9 + exit; // Exit if accessed directly
10 +}
11 +
12 +class MxChat_Knowledge_Manager {
13 +
14 + private $options;
15 +
16 + /**
17 + * Constructor - Register hooks for content processing
18 + */
19 +public function __construct() {
20 + $this->options = get_option('mxchat_options', array());
21 + $this->mxchat_init_hooks();
22 +
23 + $this->mxchat_init_role_hooks();
24 +}
25 +
26 +/**
27 + * Initialize WordPress hooks for content processing
28 + *
29 + */
30 +private function mxchat_init_hooks() {
31 + // Admin post handlers for form submissions
32 + add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 + add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 + add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
35 +
36 + // AJAX handlers for real-time processing and status updates
37 + add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
38 + add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
39 + add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
40 + add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
41 + add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
42 + add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
43 + add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
44 + add_action('wp_ajax_mxchat_delete_chunks_by_url', array($this, 'ajax_mxchat_delete_chunks_by_url'));
45 + add_action('wp_ajax_mxchat_delete_wordpress_prompt', array($this, 'ajax_mxchat_delete_wordpress_prompt'));
46 + add_action('wp_ajax_mxchat_bulk_delete_knowledge', array($this, 'ajax_mxchat_bulk_delete_knowledge'));
47 + add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
48 +
49 + // Queue-based processing AJAX handlers
50 + add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
51 + add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
52 + add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
53 + add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
54 + add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
55 + add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
56 + add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
57 + add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
58 + add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
59 +
60 + // Hook for content deletion
61 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
62 +
63 + // WordPress post management hooks
64 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
65 + add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
66 + add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
67 + add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
68 +
69 + // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
70 + // Priority 20 to run after ACF's own save (which runs at priority 10)
71 + add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
72 +
73 + add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
74 +
75 + // WooCommerce product hooks (if WooCommerce is active)
76 + if (class_exists('WooCommerce')) {
77 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
78 + add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
79 + add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
80 + add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
81 + }
82 +}
83 +
84 + /**
85 + * Get current options (refreshed)
86 + */
87 + private function mxchat_get_options() {
88 + if (empty($this->options)) {
89 + $this->options = get_option('mxchat_options', array());
90 + }
91 + return $this->options;
92 + }
93 +
94 +
95 + // ========================================
96 + // MAIN CONTENT SUBMISSION HANDLERS
97 + // ========================================
98 +
99 +public function mxchat_handle_content_submission() {
100 + // Check if the form was submitted and the user has permission.
101 + if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
102 + return;
103 + }
104 +
105 + // Verify the nonce.
106 + $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
107 + if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
108 + wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
109 + }
110 +
111 + // Sanitize the inputs.
112 + // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
113 + $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
114 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
115 +
116 + // Get bot_id from form submission
117 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
118 +
119 + // Get bot-specific options and API key
120 + $bot_options = $this->get_bot_options($bot_id);
121 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
122 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
123 +
124 + if (strpos($selected_model, 'voyage') === 0) {
125 + $api_key = $options['voyage_api_key'] ?? '';
126 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
127 + $api_key = $options['gemini_api_key'] ?? '';
128 + } else {
129 + $api_key = $options['api_key'] ?? '';
130 + }
131 +
132 + if (empty($api_key)) {
133 + set_transient('mxchat_admin_notice_error',
134 + esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
135 + 30
136 + );
137 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
138 + exit;
139 + }
140 +
141 + // Use centralized utility function with bot_id
142 + $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
143 +
144 + if (is_wp_error($result)) {
145 + set_transient('mxchat_admin_notice_error',
146 + esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
147 + 30
148 + );
149 + } else {
150 + set_transient('mxchat_admin_notice_success',
151 + esc_html__('Content successfully submitted!', 'mxchat'),
152 + 30
153 + );
154 + }
155 +
156 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
157 + exit;
158 +}
159 +
160 +public function mxchat_is_pdf_url($url, $response) {
161 + $content_type = wp_remote_retrieve_header($response, 'content-type');
162 + $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
163 +
164 + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf';
165 +}
166 +
167 +
168 +public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
169 + if (!current_user_can('manage_options')) {
170 + return false;
171 + }
172 +
173 + $pdf_url = esc_url_raw($pdf_url);
174 + $upload_dir = wp_upload_dir();
175 +
176 + if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
177 + return false;
178 + }
179 +
180 + $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
181 + $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
182 +
183 + $response_body = wp_remote_retrieve_body($response);
184 + if (empty($response_body)) {
185 + return false;
186 + }
187 +
188 + if (!wp_mkdir_p(dirname($pdf_path))) {
189 + return false;
190 + }
191 +
192 + try {
193 + file_put_contents($pdf_path, $response_body);
194 +
195 + if (!file_exists($pdf_path)) {
196 + throw new Exception(__('Failed to save PDF file', 'mxchat'));
197 + }
198 +
199 + $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
200 +
201 + if ($total_pages === false || $total_pages < 1) {
202 + throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
203 + }
204 +
205 + // Create unique queue ID
206 + $queue_id = 'pdf_' . md5($pdf_url . time());
207 +
208 + // Create array of pages to process
209 + $pages = array();
210 + for ($i = 1; $i <= $total_pages; $i++) {
211 + $pages[] = array(
212 + 'pdf_path' => $pdf_path,
213 + 'pdf_url' => $pdf_url,
214 + 'page_number' => $i,
215 + 'total_pages' => $total_pages
216 + );
217 + }
218 +
219 + // Add pages to queue
220 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
221 +
222 + if ($queued_count === 0) {
223 + wp_delete_file($pdf_path);
224 + throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
225 + }
226 +
227 + // Store queue metadata
228 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
229 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
230 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
231 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
232 + $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
233 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
234 +
235 + // Store queue ID in transient for status tracking
236 + set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
237 + set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
238 +
239 + return 'queued';
240 +
241 + } catch (Exception $e) {
242 + if (file_exists($pdf_path)) {
243 + wp_delete_file($pdf_path);
244 + }
245 + return $e->getMessage();
246 + }
247 +}
248 +
249 +/**
250 + * Validate PDF and count pages with multiple parser attempts
251 + */
252 +private function mxchat_validate_and_count_pdf_pages($pdf_path) {
253 + // Method 1: Try with Smalot PDF Parser (your current method)
254 + try {
255 + $parser = new \Smalot\PdfParser\Parser();
256 + $pdf = $parser->parseFile($pdf_path);
257 + $pages = $pdf->getPages();
258 + $page_count = count($pages);
259 +
260 + if ($page_count > 0) {
261 + //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
262 + return $page_count;
263 + }
264 + } catch (Exception $e) {
265 + //error_log('Smalot PDF parser failed: ' . $e->getMessage());
266 + }
267 +
268 + // Method 2: Try with pdfinfo command (if available)
269 + if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
270 + try {
271 + $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
272 + $output = shell_exec($command);
273 +
274 + if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
275 + $page_count = intval($matches[1]);
276 + if ($page_count > 0) {
277 + //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
278 + return $page_count;
279 + }
280 + }
281 + } catch (Exception $e) {
282 + //error_log('pdfinfo command failed: ' . $e->getMessage());
283 + }
284 + }
285 +
286 + // Method 3: Try to repair PDF and parse again
287 + try {
288 + $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
289 + if ($repaired_path && $repaired_path !== $pdf_path) {
290 + $parser = new \Smalot\PdfParser\Parser();
291 + $pdf = $parser->parseFile($repaired_path);
292 + $pages = $pdf->getPages();
293 + $page_count = count($pages);
294 +
295 + if ($page_count > 0) {
296 + // Replace original with repaired version
297 + copy($repaired_path, $pdf_path);
298 + unlink($repaired_path);
299 + //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
300 + return $page_count;
301 + }
302 +
303 + // Clean up repaired file if it didn't work
304 + unlink($repaired_path);
305 + }
306 + } catch (Exception $e) {
307 + //error_log('PDF repair attempt failed: ' . $e->getMessage());
308 + }
309 +
310 + // Method 4: Manual PDF structure analysis (basic page count)
311 + try {
312 + $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
313 + if ($page_count > 0) {
314 + //error_log('PDF page count determined manually: ' . $page_count . ' pages');
315 + return $page_count;
316 + }
317 + } catch (Exception $e) {
318 + //error_log('Manual PDF analysis failed: ' . $e->getMessage());
319 + }
320 +
321 + //error_log('All PDF parsing methods failed for: ' . $pdf_path);
322 + return false;
323 +}
324 +
325 +/**
326 + * Check if shell_exec is disabled
327 + */
328 +private function mxchat_is_shell_disabled() {
329 + $disabled = explode(',', ini_get('disable_functions'));
330 + return in_array('shell_exec', $disabled);
331 +}
332 +
333 +/**
334 + * Attempt to repair PDF using basic methods
335 + */
336 +private function mxchat_attempt_pdf_repair($pdf_path) {
337 + try {
338 + $content = file_get_contents($pdf_path);
339 + if (!$content) {
340 + return false;
341 + }
342 +
343 + // Check if PDF starts with proper header
344 + if (substr($content, 0, 4) !== '%PDF') {
345 + // Try to find PDF header in the content
346 + $header_pos = strpos($content, '%PDF');
347 + if ($header_pos !== false && $header_pos < 1024) {
348 + // Remove junk before PDF header
349 + $content = substr($content, $header_pos);
350 + $repaired_path = $pdf_path . '.repaired';
351 + file_put_contents($repaired_path, $content);
352 + return $repaired_path;
353 + }
354 + }
355 +
356 + // Check for EOF marker
357 + $content = rtrim($content);
358 + if (!preg_match('/%%EOF\s*$/', $content)) {
359 + // Add EOF marker if missing
360 + $content .= "\n%%EOF";
361 + $repaired_path = $pdf_path . '.repaired';
362 + file_put_contents($repaired_path, $content);
363 + return $repaired_path;
364 + }
365 +
366 + } catch (Exception $e) {
367 + //error_log('PDF repair error: ' . $e->getMessage());
368 + }
369 +
370 + return false;
371 +}
372 +
373 +/**
374 + * Manual PDF page counting by analyzing PDF structure
375 + */
376 +private function mxchat_manual_pdf_page_count($pdf_path) {
377 + try {
378 + $content = file_get_contents($pdf_path);
379 + if (!$content) {
380 + return 0;
381 + }
382 +
383 + // Method 1: Count /Type /Page objects
384 + $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
385 + if ($page_count > 0) {
386 + return $page_count;
387 + }
388 +
389 + // Method 2: Look for /Count in pages object
390 + if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
391 + return intval($matches[1]);
392 + }
393 +
394 + // Method 3: Count page references
395 + $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
396 + if ($page_count > 0) {
397 + return $page_count;
398 + }
399 +
400 + } catch (Exception $e) {
401 + //error_log('Manual PDF analysis error: ' . $e->getMessage());
402 + }
403 +
404 + return 0;
405 +}
406 +
407 +
408 +public function mxchat_save_inline_prompt() {
409 + // DEBUG: Log what we're receiving
410 + //error_log('=== MXCHAT DEBUG ===');
411 + //error_log('POST data: ' . print_r($_POST, true));
412 + //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
413 +
414 + // Check for nonce security
415 + check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
416 +
417 + // If we get here, nonce passed
418 + //error_log('Nonce verification PASSED');
419 +
420 + // Verify permissions
421 + if (!current_user_can('manage_options')) {
422 + wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
423 + return;
424 + }
425 +
426 + global $wpdb;
427 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
428 +
429 + // Validate and sanitize input data
430 + $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
431 + $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
432 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
433 +
434 + if ($prompt_id > 0 && !empty($article_content)) {
435 + // Re-generate the embedding vector for the updated content
436 + $embedding_vector = $this->mxchat_generate_embedding($article_content);
437 + if (is_array($embedding_vector)) {
438 + // Serialize the embedding vector before storing it
439 + $embedding_vector_serialized = serialize($embedding_vector);
440 + // Update the prompt in the database
441 + $updated = $wpdb->update(
442 + $table_name,
443 + array(
444 + 'article_content' => $article_content,
445 + 'embedding_vector' => $embedding_vector_serialized,
446 + 'source_url' => $article_url,
447 + ),
448 + array('id' => $prompt_id),
449 + array('%s', '%s', '%s'),
450 + array('%d')
451 + );
452 + if ($updated !== false) {
453 + wp_send_json_success();
454 + } else {
455 + MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
456 + wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
457 + }
458 + } else {
459 + MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
460 + wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
461 + }
462 + } else {
463 + wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
464 + }
465 +}
466 +
467 +
468 +public function mxchat_get_pdf_processing_status($pdf_url) {
469 + $pdf_url = esc_url_raw($pdf_url);
470 + $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
471 +
472 + if (!$status || !is_array($status)) {
473 + return false;
474 + }
475 +
476 + // Check for stalled processing (no updates for 5 minutes)
477 + if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
478 + $status['status'] = 'error';
479 + $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
480 +
481 + // Save the updated status
482 + set_transient(
483 + sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
484 + array_map('sanitize_text_field', $status),
485 + DAY_IN_SECONDS
486 + );
487 + }
488 +
489 + $result = array(
490 + 'total_pages' => absint($status['total_pages']),
491 + 'processed_pages' => absint($status['processed_pages']),
492 + 'failed_pages' => absint($status['failed_pages'] ?? 0),
493 + 'percentage' => ($status['total_pages'] > 0)
494 + ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
495 + : 0,
496 + 'status' => sanitize_text_field($status['status']),
497 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
498 + 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
499 + 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
500 + );
501 +
502 + // Add error message if present
503 + if (isset($status['error']) && !empty($status['error'])) {
504 + $result['error'] = sanitize_text_field($status['error']);
505 + }
506 +
507 + return $result;
508 +}
509 +
510 +
511 +public function mxchat_handle_sitemap_submission() {
512 + // Check if the form was submitted and verify permissions
513 + if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
514 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
515 + }
516 +
517 + // Verify nonce
518 + check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
519 +
520 + // Validate URL
521 + if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
522 + set_transient('mxchat_admin_notice_error',
523 + esc_html__('Please provide a valid URL.', 'mxchat'),
524 + 30
525 + );
526 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
527 + exit;
528 + }
529 +
530 + $submitted_url = esc_url_raw($_POST['sitemap_url']);
531 +
532 + // Get bot_id from form submission
533 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
534 +
535 + // Get bot-specific options and validate API key
536 + $bot_options = $this->get_bot_options($bot_id);
537 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
538 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
539 +
540 + if (strpos($selected_model, 'voyage') === 0) {
541 + $api_key = $options['voyage_api_key'] ?? '';
542 + $provider_name = 'Voyage AI';
543 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
544 + $api_key = $options['gemini_api_key'] ?? '';
545 + $provider_name = 'Google Gemini';
546 + } else {
547 + $api_key = $options['api_key'] ?? '';
548 + $provider_name = 'OpenAI';
549 + }
550 +
551 + if (empty($api_key)) {
552 + $error_message = sprintf(
553 + esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
554 + $provider_name
555 + );
556 + set_transient('mxchat_admin_notice_error', $error_message, 30);
557 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
558 + exit;
559 + }
560 +
561 + // Fetch URL
562 + $response = wp_remote_get($submitted_url, array('timeout' => 30));
563 +
564 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
565 + $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
566 + set_transient('mxchat_admin_notice_error',
567 + sprintf(
568 + esc_html__('Failed to fetch the URL: %s', 'mxchat'),
569 + esc_html($error_message)
570 + ),
571 + 30
572 + );
573 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
574 + exit;
575 + }
576 +
577 + $content_type = wp_remote_retrieve_header($response, 'content-type');
578 + $body_content = wp_remote_retrieve_body($response);
579 +
580 + if (empty($body_content)) {
581 + set_transient('mxchat_admin_notice_error',
582 + esc_html__('Empty response received from URL.', 'mxchat'),
583 + 30
584 + );
585 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
586 + exit;
587 + }
588 +
589 + // Handle PDF URL
590 + if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
591 + $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
592 +
593 + if ($result === 'queued') {
594 + set_transient('mxchat_admin_notice_success',
595 + esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
596 + 30
597 + );
598 + } else {
599 + set_transient('mxchat_admin_notice_error',
600 + esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
601 + 30
602 + );
603 + }
604 +
605 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
606 + exit;
607 + }
608 +
609 + // Handle Sitemap XML
610 + if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
611 + libxml_use_internal_errors(true);
612 + $xml = simplexml_load_string($body_content);
613 + $xml_errors = libxml_get_errors();
614 + libxml_clear_errors();
615 +
616 + if ($xml === false || !empty($xml_errors)) {
617 + set_transient('mxchat_admin_notice_error',
618 + esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
619 + 30
620 + );
621 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
622 + exit;
623 + }
624 +
625 + $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
626 +
627 + if ($result === 'queued') {
628 + set_transient('mxchat_admin_notice_success',
629 + esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
630 + 30
631 + );
632 + } else {
633 + set_transient('mxchat_admin_notice_error',
634 + esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
635 + 30
636 + );
637 + }
638 +
639 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
640 + exit;
641 + }
642 +
643 + // Handle Regular URL (single page)
644 + $page_content = $this->mxchat_extract_main_content($body_content);
645 + $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
646 +
647 + error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
648 + error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
649 + error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
650 + error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
651 +
652 + if (empty($sanitized_content)) {
653 + set_transient('mxchat_admin_notice_error',
654 + esc_html__('No valid content found on the provided URL.', 'mxchat'),
655 + 30
656 + );
657 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
658 + exit;
659 + }
660 +
661 + // For single URLs, process immediately using submit_content_to_db
662 + // This handles chunking automatically for large content
663 + $db_result = MxChat_Utils::submit_content_to_db(
664 + $sanitized_content,
665 + $submitted_url,
666 + $api_key,
667 + null,
668 + $bot_id,
669 + 'url' // content_type
670 + );
671 +
672 + if (is_wp_error($db_result)) {
673 + $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
674 + set_transient('mxchat_admin_notice_error', $error_message, 30);
675 + } else {
676 + $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
677 + set_transient('mxchat_admin_notice_success', $success_message, 30);
678 + }
679 +
680 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
681 + exit;
682 +}
683 +
684 +
685 +public function mxchat_get_single_url_status() {
686 + $status = get_transient('mxchat_single_url_status');
687 + if (!$status) {
688 + return null;
689 + }
690 +
691 + // Add human-readable time
692 + if (isset($status['timestamp'])) {
693 + $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
694 + }
695 +
696 + return $status;
697 +}
698 +
699 +public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
700 + if (!current_user_can('manage_options')) {
701 + return false;
702 + }
703 +
704 + try {
705 + $sitemap_url = esc_url_raw($sitemap_url);
706 +
707 + if (!$xml || !is_object($xml)) {
708 + throw new Exception(__('Invalid XML object provided', 'mxchat'));
709 + }
710 +
711 + // Get bot-specific embedding API for validation
712 + $bot_options = $this->get_bot_options($bot_id);
713 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
714 +
715 + // Test the embedding API before processing
716 + $test_phrase = "Test embedding generation for MxChat";
717 + $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
718 +
719 + if (is_string($test_result)) {
720 + throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
721 + }
722 +
723 + if (!is_array($test_result)) {
724 + throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
725 + }
726 +
727 + // Extract URLs from sitemap
728 + $urls = array();
729 + foreach ($xml->url as $url_element) {
730 + $url = esc_url_raw((string)$url_element->loc);
731 + if ($url) {
732 + $urls[] = array('url' => $url);
733 + }
734 + }
735 +
736 + $total_urls = count($urls);
737 +
738 + if ($total_urls < 1) {
739 + throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
740 + }
741 +
742 + // Create unique queue ID
743 + $queue_id = 'sitemap_' . md5($sitemap_url . time());
744 +
745 + // Add URLs to queue
746 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
747 +
748 + if ($queued_count === 0) {
749 + throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
750 + }
751 +
752 + // Store queue metadata
753 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
754 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
755 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
756 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
757 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
758 +
759 + // Store queue ID in transient for status tracking
760 + set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
761 + set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
762 +
763 + return 'queued';
764 +
765 + } catch (Exception $e) {
766 + $error_message = $e->getMessage();
767 + error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
768 +
769 + return $error_message;
770 + }
771 +
772 +}
773 +
774 +/**
775 + * Remove shortcode tags but preserve the content inside them
776 + * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
777 + *
778 + * @param string $content The content containing shortcodes
779 + * @return string Content with shortcode tags removed but inner content preserved
780 + */
781 +private function strip_shortcode_tags_preserve_content($content) {
782 + // Handle nested shortcodes by running multiple passes
783 + $prev_content = '';
784 + $max_iterations = 10; // Prevent infinite loops
785 + $iteration = 0;
786 + while ($prev_content !== $content && $iteration < $max_iterations) {
787 + $prev_content = $content;
788 + // Replace paired shortcodes [tag]content[/tag] with just the content
789 + $content = preg_replace('/\[([a-zA-Z0-9_-]+)[^\]]*\](.*?)\[\/\1\]/s', '$2', $content);
790 + $iteration++;
791 + }
792 + // Remove self-closing shortcodes [tag /] or [tag attr="val" /]
793 + $content = preg_replace('/\[[a-zA-Z0-9_-]+[^\]]*\/\]/', '', $content);
794 + // Remove any remaining opening shortcode tags [tag] or [tag attr="val"]
795 + $content = preg_replace('/\[[a-zA-Z0-9_-]+[^\]]*\]/', '', $content);
796 +
797 + return $content;
798 +}
799 +
800 +public function mxchat_sanitize_content_for_api($content) {
801 + //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
802 +
803 + // Remove shortcode tags but PRESERVE content inside them
804 + $content = $this->strip_shortcode_tags_preserve_content($content);
805 +
806 + // Remove script, style tags, and HTML comments
807 + $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
808 + $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
809 + $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
810 +
811 + // Remove all HTML tags and decode HTML entities
812 + $content = wp_strip_all_tags($content);
813 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
814 +
815 + // Normalize whitespace but preserve paragraph breaks
816 + // First, normalize line endings to \n
817 + $content = str_replace(["\r\n", "\r"], "\n", $content);
818 + // Replace multiple spaces/tabs with single space, but preserve newlines
819 + $content = preg_replace('/[ \t]+/', ' ', $content);
820 + // Replace 3+ newlines with 2 newlines (max 2 blank lines)
821 + $content = preg_replace('/\n{3,}/', "\n\n", $content);
822 + // Trim each line
823 + $lines = explode("\n", $content);
824 + $lines = array_map('trim', $lines);
825 + $content = implode("\n", $lines);
826 + // Final trim
827 + $content = trim($content);
828 +
829 + // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
830 + $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
831 +
832 + // Remove NULL bytes which can cause database errors
833 + $content = str_replace("\0", "", $content);
834 +
835 + // Ensure valid UTF-8 encoding
836 + $content = wp_check_invalid_utf8($content);
837 +
838 + // Remove any extremely long strings without spaces (often garbage)
839 + $content = preg_replace('/\S{300,}/', ' ', $content);
840 +
841 + // Replace problematic characters that often cause database issues
842 + $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
843 +
844 + // Replace any remaining potentially problematic characters with spaces
845 + // BUT preserve newlines by temporarily replacing them
846 + $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
847 + $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
848 + $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
849 +
850 + // Limit to reasonable length if needed
851 + $max_length = 65000; // Just under MySQL TEXT field limit
852 + if (strlen($content) > $max_length) {
853 + $content = substr($content, 0, $max_length);
854 + }
855 +
856 + //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
857 + return $content;
858 +}
859 +public function mxchat_extract_main_content($html) {
860 + if (empty($html)) {
861 + return '';
862 + }
863 + try {
864 + $dom = new DOMDocument;
865 + libxml_use_internal_errors(true); // Suppress HTML parsing errors
866 + @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
867 + $xpath = new DOMXPath($dom);
868 +
869 + // For debugging purposes
870 + $debugEnabled = true; // Set to true to enable debugging output
871 + $debug = function($message) use ($debugEnabled) {
872 + if ($debugEnabled) {
873 + error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
874 + }
875 + };
876 +
877 + // Direct targeting for Gerow theme posts
878 + $post_text = $xpath->query('//div[contains(@class, "post-text")]');
879 + if ($post_text && $post_text->length > 0) {
880 + $debug("Found post-text directly");
881 + $content = '';
882 + foreach ($post_text as $node) {
883 + $content .= $dom->saveHTML($node);
884 + }
885 + if (!empty($content)) {
886 + $debug("Returning post-text content");
887 + return $content;
888 + }
889 + }
890 +
891 + // Try to get the blog details content which contains the post-text
892 + $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
893 + if ($blog_details && $blog_details->length > 0) {
894 + $debug("Found blog-details-content");
895 + $content = '';
896 + foreach ($blog_details as $node) {
897 + $content .= $dom->saveHTML($node);
898 + }
899 + if (!empty($content)) {
900 + $debug("Returning blog-details-content");
901 + return $content;
902 + }
903 + }
904 +
905 + // Try to get the article which contains the blog details
906 + $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
907 + if ($article && $article->length > 0) {
908 + $debug("Found article with blog-details-wrap");
909 + $content = '';
910 + foreach ($article as $node) {
911 + $content .= $dom->saveHTML($node);
912 + }
913 + if (!empty($content)) {
914 + $debug("Returning article content");
915 + return $content;
916 + }
917 + }
918 +
919 + // Try even broader with the blog-item-wrap
920 + $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
921 + if ($blog_item && $blog_item->length > 0) {
922 + $debug("Found blog-item-wrap");
923 + $content = '';
924 + foreach ($blog_item as $node) {
925 + $content .= $dom->saveHTML($node);
926 + }
927 + if (!empty($content)) {
928 + $debug("Returning blog-item-wrap content");
929 + return $content;
930 + }
931 + }
932 +
933 + // Specific Gerow theme path
934 + $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
935 + if ($gerow_path && $gerow_path->length > 0) {
936 + $debug("Found Gerow theme path to post-text");
937 + $content = '';
938 + foreach ($gerow_path as $node) {
939 + $content .= $dom->saveHTML($node);
940 + }
941 + if (!empty($content)) {
942 + $debug("Returning Gerow post-text content");
943 + return $content;
944 + }
945 + }
946 +
947 + // Generic blog post selectors
948 + $selectors = [
949 + // Blog post specific selectors
950 + '//div[contains(@class, "post-text")]',
951 + '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
952 + '//div[contains(@class, "blog-details-content")]',
953 + '//article[contains(@class, "blog-details-wrap")]',
954 + '//div[contains(@class, "entry-content")]',
955 + '//div[contains(@class, "blog-content")]',
956 + '//div[contains(@class, "blog-item-wrap")]',
957 +
958 + // More general content selectors
959 + '//div[contains(@class, "page__content")]',
960 + '//div[contains(@class, "elementor-widget-container")]',
961 + '//div[contains(@class, "elementor-text-editor")]',
962 + '//div[contains(@class, "elementor-widget-text-editor")]',
963 + '//*[contains(@class, "entry-content")]',
964 + '//*[contains(@class, "post-content")]',
965 + '//*[contains(@class, "article-content")]',
966 + '//*[@id="content"]',
967 + '//*[@id="main-content"]',
968 + '//section[contains(@class, "blog-area")]',
969 + '//article',
970 + '//main',
971 + '//div[contains(@class, "content")]'
972 + ];
973 +
974 + // First handle Elementor content - get only leaf widget containers to avoid duplicates
975 + $debug("Checking for Elementor content");
976 + // Get widget containers that are direct children of widgets (not nested inside other widget containers)
977 + $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
978 + if ($elementor_widgets && $elementor_widgets->length > 0) {
979 + $debug("Found Elementor widgets");
980 + $seen_content = array(); // Track seen content to avoid duplicates
981 + $combined_content = '';
982 + foreach ($elementor_widgets as $widget) {
983 + $widget_content = $dom->saveHTML($widget);
984 + if (!empty($widget_content)) {
985 + // Create a hash of the content to detect duplicates
986 + $content_hash = md5($widget_content);
987 + if (!isset($seen_content[$content_hash])) {
988 + $seen_content[$content_hash] = true;
989 + $combined_content .= $widget_content;
990 + }
991 + }
992 + }
993 + if (!empty($combined_content)) {
994 + $debug("Returning Elementor content");
995 + return $combined_content;
996 + }
997 + }
998 +
999 + // Try standard selectors one by one
1000 + foreach ($selectors as $selector) {
1001 + $debug("Trying selector: " . $selector);
1002 + $nodes = $xpath->query($selector);
1003 + if ($nodes && $nodes->length > 0) {
1004 + $debug("Found " . $nodes->length . " matches for selector: " . $selector);
1005 + // Only take the FIRST matching node to avoid duplicate content
1006 + // (pages often have nested or multiple containers with same class)
1007 + $content = $dom->saveHTML($nodes->item(0));
1008 + if (!empty($content)) {
1009 + $debug("Returning content from selector: " . $selector . " (first match only)");
1010 + return $content;
1011 + }
1012 + }
1013 + }
1014 +
1015 + // Manual regex fallback for post-text if DOM methods fail
1016 + $debug("Trying regex fallback");
1017 + if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
1018 + $debug("Found post-text via regex");
1019 + return '<div class="post-text">' . $matches[1] . '</div>';
1020 + }
1021 +
1022 + // Try to extract the blog section as a whole
1023 + $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
1024 + if ($blog_section && $blog_section->length > 0) {
1025 + $debug("Found blog-area section");
1026 + $content = '';
1027 + foreach ($blog_section as $node) {
1028 + $content .= $dom->saveHTML($node);
1029 + }
1030 + if (!empty($content)) {
1031 + $debug("Returning blog-area section content");
1032 + return $content;
1033 + }
1034 + }
1035 +
1036 + // Generic container selectors for non-CMS sites (like .asp pages)
1037 + $debug("Trying generic container selectors");
1038 + $generic_selectors = [
1039 + '//div[@id="main"]',
1040 + '//div[@id="wrapper"]',
1041 + '//div[@id="page"]',
1042 + '//div[@id="site-content"]',
1043 + '//div[contains(@class, "main-content")]',
1044 + '//div[contains(@class, "page-content")]',
1045 + '//div[contains(@class, "site-content")]',
1046 + ];
1047 +
1048 + foreach ($generic_selectors as $selector) {
1049 + $debug("Trying generic selector: " . $selector);
1050 + $nodes = $xpath->query($selector);
1051 + if ($nodes && $nodes->length > 0) {
1052 + $content = $dom->saveHTML($nodes->item(0));
1053 + if (!empty($content)) {
1054 + $debug("Returning content from generic selector: " . $selector);
1055 + return $content;
1056 + }
1057 + }
1058 + }
1059 +
1060 + // Paragraph-based content detection - find regions with substantial text
1061 + $debug("Trying paragraph-based content detection");
1062 + $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
1063 + if ($paragraphs && $paragraphs->length >= 3) {
1064 + $debug("Found " . $paragraphs->length . " substantial paragraphs");
1065 + // Collect all substantial paragraphs and their content
1066 + $paragraph_content = '';
1067 + foreach ($paragraphs as $p) {
1068 + $paragraph_content .= $dom->saveHTML($p) . "\n";
1069 + }
1070 + if (!empty($paragraph_content)) {
1071 + $debug("Returning paragraph-based content");
1072 + return $paragraph_content;
1073 + }
1074 + }
1075 +
1076 + // Improved body fallback - strip nav/header/footer elements first
1077 + $debug("Using improved body fallback");
1078 + $body = $dom->getElementsByTagName('body');
1079 + if ($body->length > 0) {
1080 + // Clone the body to avoid modifying the original DOM
1081 + $body_clone = $body->item(0)->cloneNode(true);
1082 +
1083 + // Remove common non-content elements by tag name
1084 + $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
1085 + foreach ($remove_tags as $tag) {
1086 + $elements = $body_clone->getElementsByTagName($tag);
1087 + // Iterate backwards to safely remove elements
1088 + for ($i = $elements->length - 1; $i >= 0; $i--) {
1089 + $el = $elements->item($i);
1090 + if ($el && $el->parentNode) {
1091 + $el->parentNode->removeChild($el);
1092 + }
1093 + }
1094 + }
1095 +
1096 + // Remove elements with common non-content class names using XPath on the cloned body
1097 + $temp_dom = new DOMDocument();
1098 + @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
1099 + $temp_xpath = new DOMXPath($temp_dom);
1100 +
1101 + $remove_class_patterns = [
1102 + '//*[contains(@class, "nav")]',
1103 + '//*[contains(@class, "menu")]',
1104 + '//*[contains(@class, "sidebar")]',
1105 + '//*[contains(@class, "footer")]',
1106 + '//*[contains(@class, "header")]',
1107 + '//*[contains(@id, "nav")]',
1108 + '//*[contains(@id, "menu")]',
1109 + '//*[contains(@id, "sidebar")]',
1110 + '//*[contains(@id, "footer")]',
1111 + '//*[contains(@id, "header")]',
1112 + ];
1113 +
1114 + foreach ($remove_class_patterns as $pattern) {
1115 + $elements = $temp_xpath->query($pattern);
1116 + if ($elements) {
1117 + for ($i = $elements->length - 1; $i >= 0; $i--) {
1118 + $el = $elements->item($i);
1119 + if ($el && $el->parentNode) {
1120 + $el->parentNode->removeChild($el);
1121 + }
1122 + }
1123 + }
1124 + }
1125 +
1126 + $cleaned_content = $temp_dom->saveHTML();
1127 + if (!empty($cleaned_content)) {
1128 + $debug("Returning cleaned body content");
1129 + return $cleaned_content;
1130 + }
1131 + }
1132 +
1133 + // Last resort: return the original HTML
1134 + $debug("Returning original HTML");
1135 + return $html;
1136 + } catch (Exception $e) {
1137 + //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
1138 + return $html; // Return original HTML if parsing fails
1139 + } finally {
1140 + libxml_clear_errors();
1141 + }
1142 +}
1143 +public function mxchat_get_sitemap_processing_status($sitemap_url) {
1144 + $sitemap_url = esc_url_raw($sitemap_url);
1145 + $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
1146 + $status = get_transient($status_key);
1147 +
1148 + if (!$status || !is_array($status)) {
1149 + return false;
1150 + }
1151 +
1152 + // Auto-complete check: if all URLs are processed but status isn't complete
1153 + if (isset($status['processed_urls']) && isset($status['total_urls']) &&
1154 + $status['processed_urls'] >= $status['total_urls'] &&
1155 + isset($status['status']) && $status['status'] !== 'complete' &&
1156 + $status['status'] !== 'error') {
1157 +
1158 + // Mark as complete
1159 + $status['status'] = 'complete';
1160 + $status['processed_urls'] = $status['total_urls']; // Ensure exact match
1161 +
1162 + // Update the transient with the corrected status
1163 + set_transient($status_key, $status, DAY_IN_SECONDS);
1164 + }
1165 +
1166 + return array(
1167 + 'total_urls' => absint($status['total_urls']),
1168 + 'processed_urls' => absint($status['processed_urls']),
1169 + 'failed_urls' => absint($status['failed_urls'] ?? 0),
1170 + 'percentage' => ($status['total_urls'] > 0)
1171 + ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
1172 + : 0,
1173 + 'status' => sanitize_text_field($status['status']),
1174 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1175 + 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
1176 + 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
1177 + 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
1178 + );
1179 +}
1180 +
1181 +public function mxchat_ajax_get_status_updates() {
1182 + try {
1183 + // Verify the request
1184 + check_ajax_referer('mxchat_status_nonce', 'nonce');
1185 +
1186 + // Get active queue IDs
1187 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1188 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1189 +
1190 + $sitemap_status = false;
1191 + $pdf_status = false;
1192 +
1193 + // Get sitemap queue status
1194 + if ($sitemap_queue_id) {
1195 + $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1196 + }
1197 +
1198 + // Get PDF queue status
1199 + if ($pdf_queue_id) {
1200 + $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1201 + }
1202 +
1203 + $is_active_processing =
1204 + ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1205 + ($pdf_status && $pdf_status['status'] === 'processing');
1206 +
1207 + // Return JSON response with the status data
1208 + wp_send_json(array(
1209 + 'pdf_status' => $pdf_status,
1210 + 'sitemap_status' => $sitemap_status,
1211 + 'is_processing' => $is_active_processing,
1212 + 'sitemap_queue_id' => $sitemap_queue_id,
1213 + 'pdf_queue_id' => $pdf_queue_id
1214 + ));
1215 +
1216 + } catch (Exception $e) {
1217 + error_log('MxChat Status Update Error: ' . $e->getMessage());
1218 +
1219 + wp_send_json_error(array(
1220 + 'message' => 'Error getting status updates: ' . $e->getMessage(),
1221 + 'status' => 'error'
1222 + ));
1223 + }
1224 +}
1225 +
1226 +/**
1227 + * Helper function to get queue status data
1228 + */
1229 +private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
1230 + global $wpdb;
1231 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
1232 +
1233 + // Get counts by status
1234 + $counts = $wpdb->get_results($wpdb->prepare(
1235 + "SELECT status, COUNT(*) as count
1236 + FROM $table_name
1237 + WHERE queue_id = %s
1238 + GROUP BY status",
1239 + $queue_id
1240 + ), OBJECT_K);
1241 +
1242 + $total = 0;
1243 + $completed = 0;
1244 + $failed = 0;
1245 + $processing = 0;
1246 + $pending = 0;
1247 +
1248 + foreach ($counts as $status => $data) {
1249 + $count = absint($data->count);
1250 + $total += $count;
1251 +
1252 + switch ($status) {
1253 + case 'completed':
1254 + $completed = $count;
1255 + break;
1256 + case 'failed':
1257 + $failed = $count;
1258 + break;
1259 + case 'processing':
1260 + $processing = $count;
1261 + break;
1262 + case 'pending':
1263 + $pending = $count;
1264 + break;
1265 + }
1266 + }
1267 +
1268 + if ($total === 0) {
1269 + return false;
1270 + }
1271 +
1272 + // Calculate percentage
1273 + $percentage = round((($completed + $failed) / $total) * 100);
1274 +
1275 + // Get failed items details (limit to 50)
1276 + $failed_items = array();
1277 + if ($failed > 0) {
1278 + $failed_results = $wpdb->get_results($wpdb->prepare(
1279 + "SELECT item_type, item_data, error_message, attempts, completed_at
1280 + FROM $table_name
1281 + WHERE queue_id = %s
1282 + AND status = 'failed'
1283 + AND attempts >= max_attempts
1284 + ORDER BY id DESC
1285 + LIMIT 50",
1286 + $queue_id
1287 + ));
1288 +
1289 + foreach ($failed_results as $item) {
1290 + $data = json_decode($item->item_data, true);
1291 + $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
1292 +
1293 + $failed_items[] = array(
1294 + 'url' => $url,
1295 + 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
1296 + 'error' => $item->error_message,
1297 + 'retries' => $item->attempts,
1298 + 'time' => strtotime($item->completed_at)
1299 + );
1300 + }
1301 + }
1302 +
1303 + // Get queue metadata
1304 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
1305 +
1306 + // Determine if queue is complete
1307 + $is_complete = ($pending === 0 && $processing === 0);
1308 +
1309 + // Get last update time
1310 + $last_update = $wpdb->get_var($wpdb->prepare(
1311 + "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
1312 + FROM $table_name
1313 + WHERE queue_id = %s",
1314 + $queue_id
1315 + ));
1316 +
1317 + $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
1318 +
1319 + // Format based on type
1320 + if ($type === 'pdf') {
1321 + return array(
1322 + 'total_pages' => $total,
1323 + 'processed_pages' => $completed + $failed,
1324 + 'failed_pages' => $failed,
1325 + 'percentage' => $percentage,
1326 + 'status' => $is_complete ? 'complete' : 'processing',
1327 + 'last_update' => $last_update_text,
1328 + 'failed_pages_list' => $failed_items,
1329 + 'pdf_url' => $source_url,
1330 + 'queue_id' => $queue_id
1331 + );
1332 + } else {
1333 + return array(
1334 + 'total_urls' => $total,
1335 + 'processed_urls' => $completed + $failed,
1336 + 'failed_urls' => $failed,
1337 + 'percentage' => $percentage,
1338 + 'status' => $is_complete ? 'complete' : 'processing',
1339 + 'last_update' => $last_update_text,
1340 + 'failed_urls_list' => $failed_items,
1341 + 'sitemap_url' => $source_url,
1342 + 'queue_id' => $queue_id
1343 + );
1344 + }
1345 +}
1346 +
1347 +/**
1348 + * Public method to get processing status for both sitemap and PDF queues
1349 + * Used by admin pages to display processing status
1350 + *
1351 + * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
1352 + */
1353 +public function mxchat_get_processing_statuses() {
1354 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1355 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1356 +
1357 + $sitemap_status = false;
1358 + $pdf_status = false;
1359 +
1360 + if ($sitemap_queue_id) {
1361 + $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1362 + }
1363 +
1364 + if ($pdf_queue_id) {
1365 + $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1366 + }
1367 +
1368 + $is_processing =
1369 + ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1370 + ($pdf_status && $pdf_status['status'] === 'processing');
1371 +
1372 + return array(
1373 + 'sitemap_status' => $sitemap_status,
1374 + 'pdf_status' => $pdf_status,
1375 + 'is_processing' => $is_processing
1376 + );
1377 +}
1378 +
1379 +/**
1380 + * AJAX handler to get recent knowledge entries for real-time table updates
1381 + * UPDATED: Now supports both WordPress DB and Pinecone data sources
1382 + */
1383 +public function ajax_mxchat_get_recent_entries() {
1384 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
1385 +
1386 + if (!current_user_can('manage_options')) {
1387 + wp_send_json_error(array('message' => 'Unauthorized'));
1388 + return;
1389 + }
1390 +
1391 + global $wpdb;
1392 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1393 +
1394 + // Get parameters
1395 + $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
1396 + $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
1397 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1398 +
1399 + // Check if Pinecone is enabled for this bot
1400 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
1401 + $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
1402 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1403 + $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
1404 +
1405 + if ($use_pinecone && $has_pinecone_api) {
1406 + // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
1407 + // Use mxchat_fetch_pinecone_records which returns total_unique_entries
1408 + $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
1409 + $total_count = $records['total'] ?? 0;
1410 +
1411 + // For Pinecone, we don't return individual entries during polling
1412 + // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
1413 + // We just return the updated count
1414 + wp_send_json_success(array(
1415 + 'entries' => array(),
1416 + 'total_count' => absint($total_count),
1417 + 'max_id' => $last_id,
1418 + 'data_source' => 'pinecone'
1419 + ));
1420 + return;
1421 + }
1422 +
1423 + // WORDPRESS DB DATA SOURCE
1424 + // Build query to get entries newer than last_id
1425 + $where_clauses = array('1=1');
1426 + $where_values = array();
1427 +
1428 + if ($last_id > 0) {
1429 + $where_clauses[] = 'id > %d';
1430 + $where_values[] = $last_id;
1431 + }
1432 +
1433 + // Note: WordPress DB table doesn't have bot_id column
1434 + // Multi-bot filtering is handled via Pinecone namespaces
1435 +
1436 + $where_sql = implode(' AND ', $where_clauses);
1437 +
1438 + // Get recent entries
1439 + $query = "SELECT id, article_content, source_url, timestamp
1440 + FROM $table_name
1441 + WHERE $where_sql
1442 + ORDER BY id DESC
1443 + LIMIT %d";
1444 +
1445 + $where_values[] = $limit;
1446 +
1447 + $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
1448 +
1449 + // Get total count of GROUPED entries (by source_url) - matches pagination display
1450 + // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
1451 + $total_count = $wpdb->get_var(
1452 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1453 + (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
1454 + );
1455 +
1456 + // Format entries for response
1457 + $formatted_entries = array();
1458 + $preview_length = 150;
1459 + foreach ($entries as $entry) {
1460 + // Parse chunk metadata using the proper chunker method (same as initial page load)
1461 + if (class_exists('MxChat_Chunker')) {
1462 + $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
1463 + $display_content = $chunk_meta['text'];
1464 + $chunk_metadata = $chunk_meta['metadata'];
1465 + } else {
1466 + $display_content = $entry->article_content;
1467 + $chunk_metadata = array();
1468 + }
1469 +
1470 + $content_preview = mb_strlen($display_content) > $preview_length
1471 + ? mb_substr($display_content, 0, $preview_length) . '...'
1472 + : $display_content;
1473 +
1474 + $formatted_entries[] = array(
1475 + 'id' => $entry->id,
1476 + 'preview' => esc_html($content_preview),
1477 + 'full_content' => wp_kses_post(wpautop($display_content)),
1478 + 'content_length' => mb_strlen($display_content),
1479 + 'preview_length' => $preview_length,
1480 + 'source_url' => $entry->source_url,
1481 + 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
1482 + 'chunk_metadata' => $chunk_metadata,
1483 + 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
1484 + );
1485 + }
1486 +
1487 + wp_send_json_success(array(
1488 + 'entries' => $formatted_entries,
1489 + 'total_count' => absint($total_count),
1490 + 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
1491 + 'data_source' => 'wordpress'
1492 + ));
1493 +}
1494 +
1495 +/**
1496 + * Get Pinecone total count from stats API
1497 + * Helper function for ajax_mxchat_get_recent_entries
1498 + */
1499 +private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
1500 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1501 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1502 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1503 +
1504 + if (empty($api_key) || empty($host)) {
1505 + return 0;
1506 + }
1507 +
1508 + try {
1509 + $stats_url = "https://{$host}/describe_index_stats";
1510 +
1511 + $response = wp_remote_post($stats_url, array(
1512 + 'headers' => array(
1513 + 'Api-Key' => $api_key,
1514 + 'Content-Type' => 'application/json'
1515 + ),
1516 + 'body' => '{}',
1517 + 'timeout' => 10
1518 + ));
1519 +
1520 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1521 + $body = wp_remote_retrieve_body($response);
1522 + $stats_data = json_decode($body, true);
1523 +
1524 + // If namespace is specified, get count from that specific namespace
1525 + if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
1526 + return intval($stats_data['namespaces'][$namespace]['vectorCount']);
1527 + }
1528 +
1529 + // If no namespace specified or namespace not found in response, use total
1530 + return intval($stats_data['totalVectorCount'] ?? 0);
1531 + }
1532 +
1533 + return 0;
1534 +
1535 + } catch (Exception $e) {
1536 + return 0;
1537 + }
1538 +}
1539 +
1540 +/**
1541 + * AJAX handler to refresh Pinecone entries table via AJAX
1542 + * Returns the table HTML for updating the UI without a full page reload
1543 + */
1544 +public function ajax_mxchat_refresh_pinecone_entries() {
1545 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
1546 +
1547 + if (!current_user_can('manage_options')) {
1548 + wp_send_json_error(array('message' => 'Unauthorized'));
1549 + return;
1550 + }
1551 +
1552 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1553 + $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
1554 + $per_page = 10;
1555 +
1556 + // Get Pinecone manager and options
1557 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
1558 + if (!$pinecone_manager) {
1559 + wp_send_json_error(array('message' => 'Pinecone manager not available'));
1560 + return;
1561 + }
1562 +
1563 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
1564 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1565 + $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1566 +
1567 + if (!$use_pinecone || empty($pinecone_api_key)) {
1568 + wp_send_json_error(array('message' => 'Pinecone not configured'));
1569 + return;
1570 + }
1571 +
1572 + // Fetch records from Pinecone
1573 + $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', $page, $per_page, $bot_id, '');
1574 + $prompts = $records['data'] ?? array();
1575 + $total_records = $records['total'] ?? 0;
1576 +
1577 + // Group prompts by source_url
1578 + $grouped_prompts = array();
1579 + foreach ($prompts as $prompt) {
1580 + $source_url = '';
1581 + if (!empty($prompt->chunk_metadata['source_url'])) {
1582 + $source_url = $prompt->chunk_metadata['source_url'];
1583 + } elseif (!empty($prompt->source_url)) {
1584 + $source_url = $prompt->source_url;
1585 + }
1586 +
1587 + if (!empty($source_url)) {
1588 + if (!isset($grouped_prompts[$source_url])) {
1589 + $grouped_prompts[$source_url] = array();
1590 + }
1591 + $grouped_prompts[$source_url][] = $prompt;
1592 + } else {
1593 + $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
1594 + }
1595 + }
1596 +
1597 + // Sort each group by chunk_index
1598 + foreach ($grouped_prompts as $source_url => &$group) {
1599 + usort($group, function($a, $b) {
1600 + $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
1601 + $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
1602 + return $index_a - $index_b;
1603 + });
1604 + }
1605 + unset($group);
1606 +
1607 + // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
1608 + ob_start();
1609 + $display_index = 0;
1610 + $current_page = $page;
1611 + $data_source = 'pinecone';
1612 + $current_bot_id = $bot_id;
1613 + $preview_length = 150;
1614 +
1615 + if (empty($grouped_prompts)) {
1616 + echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
1617 + esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
1618 + echo '</td></tr>';
1619 + } else {
1620 + foreach ($grouped_prompts as $source_url => $group) {
1621 + $chunk_count = count($group);
1622 + $first_prompt = $group[0];
1623 + $display_index++;
1624 +
1625 + if ($chunk_count > 1) {
1626 + // Multiple chunks - show grouped row with expand button
1627 + $group_id = 'group-' . md5($source_url);
1628 + ?>
1629 + <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
1630 + class="mxchat-chunk-group-header"
1631 + data-source="<?php echo esc_attr($data_source); ?>"
1632 + data-group-id="<?php echo esc_attr($group_id); ?>"
1633 + style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
1634 + <td style="padding: 12px 16px; font-size: 13px;">
1635 + <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
1636 + </td>
1637 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1638 + <div class="mxchat-chunk-group-info">
1639 + <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
1640 + <span class="dashicons dashicons-arrow-right-alt2"></span>
1641 + </button>
1642 + <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
1643 + <span class="mxchat-chunk-preview">
1644 + <?php
1645 + $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
1646 + $content_preview = mb_substr($parent_content, 0, 100);
1647 + echo esc_html($content_preview . '...');
1648 + ?>
1649 + </span>
1650 + </div>
1651 + </td>
1652 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1653 + <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
1654 + <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
1655 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
1656 + <?php esc_html_e('View Source', 'mxchat'); ?>
1657 + </a>
1658 + <?php else : ?>
1659 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
1660 + <?php endif; ?>
1661 + </td>
1662 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
1663 + <button type="button"
1664 + class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
1665 + data-source-url="<?php echo esc_attr($source_url); ?>"
1666 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
1667 + data-data-source="<?php echo esc_attr($data_source); ?>"
1668 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
1669 + data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
1670 + style="color: var(--mxch-error);"
1671 + title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
1672 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
1673 + </button>
1674 + </td>
1675 + </tr>
1676 + <?php
1677 + // Render hidden chunk rows
1678 + foreach ($group as $chunk_index => $chunk) {
1679 + $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
1680 + $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
1681 + $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
1682 + $content_preview = mb_strlen($content) > $preview_length
1683 + ? mb_substr($content, 0, $preview_length) . '...'
1684 + : $content;
1685 + ?>
1686 + <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
1687 + class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
1688 + data-source="<?php echo esc_attr($data_source); ?>"
1689 + style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
1690 + <td style="padding: 12px 16px; text-align: center;">
1691 + <!-- Checkbox column placeholder for chunks (managed by group) -->
1692 + </td>
1693 + <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
1694 + <!-- Hidden ID column for chunks -->
1695 + </td>
1696 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1697 + <div class="mxchat-accordion-wrapper">
1698 + <div class="mxchat-content-preview">
1699 + <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
1700 + <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
1701 + </span>
1702 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
1703 + <?php if (mb_strlen($content) > $preview_length) : ?>
1704 + <button class="mxchat-expand-toggle" type="button">
1705 + <span class="dashicons dashicons-arrow-down-alt2"></span>
1706 + </button>
1707 + <?php endif; ?>
1708 + </div>
1709 + <div class="mxchat-content-full" style="display: none;">
1710 + <div class="content-view">
1711 + <?php
1712 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
1713 + echo '<div dir="rtl" lang="he" class="rtl-content">';
1714 + echo wp_kses_post(wpautop($content));
1715 + echo '</div>';
1716 + } else {
1717 + echo wp_kses_post(wpautop($content));
1718 + }
1719 + ?>
1720 + </div>
1721 + </div>
1722 + </div>
1723 + </td>
1724 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1725 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
1726 + </td>
1727 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
1728 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
1729 + </td>
1730 + </tr>
1731 + <?php
1732 + }
1733 + } else {
1734 + // Single entry - display normally with accordion
1735 + $prompt = $first_prompt;
1736 + $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
1737 + $content_preview = mb_strlen($content) > $preview_length
1738 + ? mb_substr($content, 0, $preview_length) . '...'
1739 + : $content;
1740 + ?>
1741 + <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
1742 + data-source="<?php echo esc_attr($data_source); ?>"
1743 + style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
1744 + <td style="padding: 12px 16px; font-size: 13px;">
1745 + <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
1746 + </td>
1747 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1748 + <div class="mxchat-accordion-wrapper">
1749 + <div class="mxchat-content-preview">
1750 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
1751 + <?php if (mb_strlen($content) > $preview_length) : ?>
1752 + <button class="mxchat-expand-toggle" type="button">
1753 + <span class="dashicons dashicons-arrow-down-alt2"></span>
1754 + </button>
1755 + <?php endif; ?>
1756 + </div>
1757 + <div class="mxchat-content-full" style="display: none;">
1758 + <div class="content-view">
1759 + <?php
1760 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
1761 + echo '<div dir="rtl" lang="he" class="rtl-content">';
1762 + echo wp_kses_post(wpautop($content));
1763 + echo '</div>';
1764 + } else {
1765 + echo wp_kses_post(wpautop($content));
1766 + }
1767 + ?>
1768 + </div>
1769 + </div>
1770 + </div>
1771 + </td>
1772 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1773 + <?php
1774 + $actual_source = $source_url;
1775 + if (strpos($source_url, '_ungrouped_') === 0) {
1776 + $actual_source = $prompt->source_url ?? '';
1777 + }
1778 + if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
1779 + <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
1780 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
1781 + <?php esc_html_e('View', 'mxchat'); ?>
1782 + </a>
1783 + <?php else : ?>
1784 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
1785 + <?php endif; ?>
1786 + </td>
1787 + <td style="padding: 12px 16px;">
1788 + <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);">
1789 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
1790 + </button>
1791 + </td>
1792 + </tr>
1793 + <?php
1794 + }
1795 + }
1796 + }
1797 + $html = ob_get_clean();
1798 +
1799 + // Generate pagination HTML for Pinecone
1800 + $total_pages = ceil($total_records / $per_page);
1801 + $pagination_html = '';
1802 + if ($total_pages > 1) {
1803 + $pagination_html = '<div class="mxchat-ajax-pagination" data-current-page="' . esc_attr($page) . '" data-total-pages="' . esc_attr($total_pages) . '">';
1804 +
1805 + // Previous button
1806 + if ($page > 1) {
1807 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
1808 + }
1809 +
1810 + // Page numbers
1811 + $start_page = max(1, $page - 2);
1812 + $end_page = min($total_pages, $page + 2);
1813 +
1814 + if ($start_page > 1) {
1815 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
1816 + if ($start_page > 2) {
1817 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
1818 + }
1819 + }
1820 +
1821 + for ($i = $start_page; $i <= $end_page; $i++) {
1822 + if ($i == $page) {
1823 + $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
1824 + } else {
1825 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
1826 + }
1827 + }
1828 +
1829 + if ($end_page < $total_pages) {
1830 + if ($end_page < $total_pages - 1) {
1831 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
1832 + }
1833 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
1834 + }
1835 +
1836 + // Next button
1837 + if ($page < $total_pages) {
1838 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
1839 + }
1840 +
1841 + $pagination_html .= '</div>';
1842 + }
1843 +
1844 + wp_send_json_success(array(
1845 + 'html' => $html,
1846 + 'pagination_html' => $pagination_html,
1847 + 'total_count' => $total_records,
1848 + 'total_pages' => $total_pages,
1849 + 'page' => $page,
1850 + 'per_page' => $per_page,
1851 + 'data_source' => 'pinecone'
1852 + ));
1853 +}
1854 +
1855 +/**
1856 + * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
1857 + * Returns paginated entries without requiring a full page reload
1858 + */
1859 +public function ajax_mxchat_paginate_entries() {
1860 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
1861 +
1862 + if (!current_user_can('manage_options')) {
1863 + wp_send_json_error(array('message' => 'Unauthorized'));
1864 + return;
1865 + }
1866 +
1867 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1868 + $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
1869 + $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
1870 + $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
1871 + $per_page = 25;
1872 +
1873 + // Check if Pinecone is enabled for this bot
1874 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
1875 + $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
1876 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1877 + $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
1878 +
1879 + if ($use_pinecone && $has_pinecone_api) {
1880 + // Delegate to Pinecone pagination handler (pass search params)
1881 + $_POST['page'] = $page;
1882 + $_POST['search'] = $search_query;
1883 + $_POST['content_type'] = $content_type_filter;
1884 + $this->ajax_mxchat_refresh_pinecone_entries();
1885 + return;
1886 + }
1887 +
1888 + // WordPress DB pagination - MUST match initial page load logic exactly
1889 + global $wpdb;
1890 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1891 + $offset = ($page - 1) * $per_page;
1892 +
1893 + // Build WHERE clause for search and content type filtering
1894 + $where_clauses = array();
1895 + $where_values = array();
1896 +
1897 + if ($search_query) {
1898 + $where_clauses[] = "article_content LIKE %s";
1899 + $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
1900 + }
1901 +
1902 + if ($content_type_filter) {
1903 + switch ($content_type_filter) {
1904 + case 'manual':
1905 + $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
1906 + break;
1907 + case 'pdf':
1908 + $where_clauses[] = "source_url LIKE '%.pdf'";
1909 + break;
1910 + case 'url':
1911 + $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
1912 + break;
1913 + }
1914 + }
1915 +
1916 + $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
1917 +
1918 + // Count grouped entries with filters applied
1919 + if (!empty($where_values)) {
1920 + $count_args = array_merge($where_values, $where_values);
1921 + $count_query = $wpdb->prepare(
1922 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1923 + (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
1924 + ...$count_args
1925 + );
1926 + $total_records = $wpdb->get_var($count_query);
1927 + } else if (!empty($where_sql)) {
1928 + // Content type filter only (no search), no prepared values needed
1929 + $total_records = $wpdb->get_var(
1930 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1931 + (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
1932 + );
1933 + } else {
1934 + // No filters
1935 + $total_records = $wpdb->get_var(
1936 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1937 + (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
1938 + );
1939 + }
1940 + $total_pages = ceil($total_records / $per_page);
1941 +
1942 + // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
1943 + if (!empty($where_values)) {
1944 + $query_args = array_merge($where_values, array($per_page, $offset));
1945 + $urls_query = $wpdb->prepare(
1946 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
1947 + {$where_sql}
1948 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
1949 + ...$query_args
1950 + );
1951 + } else if (!empty($where_sql)) {
1952 + $urls_query = $wpdb->prepare(
1953 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
1954 + {$where_sql}
1955 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
1956 + $per_page, $offset
1957 + );
1958 + } else {
1959 + $urls_query = $wpdb->prepare(
1960 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
1961 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
1962 + $per_page, $offset
1963 + );
1964 + }
1965 + $page_urls = $wpdb->get_results($urls_query);
1966 +
1967 + // Step 2: Build list of source_urls to fetch
1968 + $url_list = array();
1969 + $url_order_map = array();
1970 + $order_index = 0;
1971 + foreach ($page_urls as $url_row) {
1972 + $url = $url_row->source_url;
1973 + $url_list[] = $url;
1974 + $url_order_map[$url] = $order_index++;
1975 + }
1976 +
1977 + // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
1978 + $prompts = array();
1979 + if (!empty($url_list)) {
1980 + $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
1981 + if ($search_query) {
1982 + // Include search filter in the final fetch
1983 + $prompts_query = $wpdb->prepare(
1984 + "SELECT id, article_content, source_url, timestamp, role_restriction
1985 + FROM {$table_name}
1986 + WHERE source_url IN ($placeholders) AND article_content LIKE %s
1987 + ORDER BY timestamp DESC",
1988 + ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
1989 + );
1990 + } else {
1991 + $prompts_query = $wpdb->prepare(
1992 + "SELECT id, article_content, source_url, timestamp, role_restriction
1993 + FROM {$table_name}
1994 + WHERE source_url IN ($placeholders)
1995 + ORDER BY timestamp DESC",
1996 + $url_list
1997 + );
1998 + }
1999 + $prompts = $wpdb->get_results($prompts_query);
2000 + }
2001 +
2002 + // Group prompts by source_url for chunk display
2003 + $grouped_prompts = array();
2004 + foreach ($prompts as $prompt) {
2005 + $source_url = $prompt->source_url ?? '';
2006 +
2007 + // Parse chunk metadata using the proper chunker method (same as initial page load)
2008 + if (class_exists('MxChat_Chunker')) {
2009 + $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
2010 + $prompt->chunk_metadata = $chunk_meta['metadata'];
2011 + $prompt->display_content = $chunk_meta['text'];
2012 + } else {
2013 + $prompt->chunk_metadata = array();
2014 + $prompt->display_content = $prompt->article_content;
2015 + }
2016 +
2017 + if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
2018 + if (!isset($grouped_prompts[$source_url])) {
2019 + $grouped_prompts[$source_url] = array();
2020 + }
2021 + $grouped_prompts[$source_url][] = $prompt;
2022 + } else {
2023 + // Ungrouped entries
2024 + $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2025 + }
2026 + }
2027 +
2028 + // Sort groups by the original URL order (newest first)
2029 + uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
2030 + $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
2031 + $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
2032 + return $order_a - $order_b;
2033 + });
2034 +
2035 + // Sort each group internally by chunk_index
2036 + foreach ($grouped_prompts as $source_url => &$group) {
2037 + usort($group, function($a, $b) {
2038 + $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2039 + $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2040 + return $index_a - $index_b;
2041 + });
2042 + }
2043 + unset($group);
2044 +
2045 + // Build HTML for the table rows
2046 + ob_start();
2047 + $display_index = 0;
2048 + $current_page = $page;
2049 + $data_source = 'wordpress';
2050 + $current_bot_id = $bot_id;
2051 + $preview_length = 150;
2052 +
2053 + if (empty($grouped_prompts)) {
2054 + echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2055 + esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
2056 + echo '</td></tr>';
2057 + } else {
2058 + foreach ($grouped_prompts as $source_url => $group) {
2059 + $chunk_count = count($group);
2060 + $first_prompt = $group[0];
2061 + $display_index++;
2062 +
2063 + if ($chunk_count > 1) {
2064 + // Multiple chunks - show grouped row with expand button
2065 + $group_id = 'group-' . md5($source_url);
2066 + ?>
2067 + <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2068 + class="mxchat-chunk-group-header"
2069 + data-source="<?php echo esc_attr($data_source); ?>"
2070 + data-group-id="<?php echo esc_attr($group_id); ?>"
2071 + style="border-bottom: 1px solid var(--mxch-card-border);">
2072 + <td style="padding: 12px 16px; text-align: center;">
2073 + <input type="checkbox"
2074 + class="mxchat-entry-checkbox"
2075 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2076 + data-source="<?php echo esc_attr($data_source); ?>"
2077 + data-source-url="<?php echo esc_attr($source_url); ?>"
2078 + data-is-group="true"
2079 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2080 + </td>
2081 + <td style="padding: 12px 16px; font-size: 13px;">
2082 + <?php echo esc_html($first_prompt->id); ?>
2083 + </td>
2084 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2085 + <div class="mxchat-chunk-group-info">
2086 + <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2087 + <span class="dashicons dashicons-arrow-right-alt2"></span>
2088 + </button>
2089 + <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2090 + <span class="mxchat-chunk-preview">
2091 + <?php
2092 + $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
2093 + $content_preview = mb_substr($parent_content, 0, 100);
2094 + echo esc_html($content_preview . '...');
2095 + ?>
2096 + </span>
2097 + </div>
2098 + </td>
2099 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2100 + <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2101 + <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2102 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2103 + <?php esc_html_e('View Source', 'mxchat'); ?>
2104 + </a>
2105 + <?php else : ?>
2106 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2107 + <?php endif; ?>
2108 + </td>
2109 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2110 + <button type="button"
2111 + class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2112 + data-source-url="<?php echo esc_attr($source_url); ?>"
2113 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2114 + data-data-source="<?php echo esc_attr($data_source); ?>"
2115 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2116 + data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2117 + style="color: var(--mxch-error);"
2118 + title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2119 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2120 + </button>
2121 + </td>
2122 + </tr>
2123 + <?php
2124 + // Render hidden chunk rows
2125 + foreach ($group as $chunk_index => $chunk) {
2126 + $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2127 + $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2128 + $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
2129 + $content_preview = mb_strlen($content) > $preview_length
2130 + ? mb_substr($content, 0, $preview_length) . '...'
2131 + : $content;
2132 + ?>
2133 + <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2134 + class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2135 + data-source="<?php echo esc_attr($data_source); ?>"
2136 + style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2137 + <td style="padding: 12px 16px; text-align: center;">
2138 + <!-- Checkbox column placeholder for chunks (managed by group) -->
2139 + </td>
2140 + <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2141 + <!-- Hidden ID column for chunks -->
2142 + </td>
2143 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2144 + <div class="mxchat-accordion-wrapper">
2145 + <div class="mxchat-content-preview">
2146 + <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2147 + <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2148 + </span>
2149 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2150 + <?php if (mb_strlen($content) > $preview_length) : ?>
2151 + <button class="mxchat-expand-toggle" type="button">
2152 + <span class="dashicons dashicons-arrow-down-alt2"></span>
2153 + </button>
2154 + <?php endif; ?>
2155 + </div>
2156 + <div class="mxchat-content-full" style="display: none;">
2157 + <div class="content-view">
2158 + <?php
2159 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2160 + echo '<div dir="rtl" lang="he" class="rtl-content">';
2161 + echo wp_kses_post(wpautop($content));
2162 + echo '</div>';
2163 + } else {
2164 + echo wp_kses_post(wpautop($content));
2165 + }
2166 + ?>
2167 + </div>
2168 + </div>
2169 + </div>
2170 + </td>
2171 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2172 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2173 + </td>
2174 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2175 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2176 + </td>
2177 + </tr>
2178 + <?php
2179 + }
2180 + } else {
2181 + // Single entry - display normally with accordion
2182 + $prompt = $first_prompt;
2183 + $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
2184 + $content_preview = mb_strlen($content) > $preview_length
2185 + ? mb_substr($content, 0, $preview_length) . '...'
2186 + : $content;
2187 + ?>
2188 + <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2189 + data-source="<?php echo esc_attr($data_source); ?>"
2190 + style="border-bottom: 1px solid var(--mxch-card-border);">
2191 + <td style="padding: 12px 16px; text-align: center;">
2192 + <input type="checkbox"
2193 + class="mxchat-entry-checkbox"
2194 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2195 + data-source="<?php echo esc_attr($data_source); ?>"
2196 + data-source-url="<?php echo esc_attr($source_url); ?>"
2197 + data-is-group="false">
2198 + </td>
2199 + <td style="padding: 12px 16px; font-size: 13px;">
2200 + <?php echo esc_html($prompt->id); ?>
2201 + </td>
2202 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2203 + <div class="mxchat-accordion-wrapper">
2204 + <div class="mxchat-content-preview">
2205 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2206 + <?php if (mb_strlen($content) > $preview_length) : ?>
2207 + <button class="mxchat-expand-toggle" type="button">
2208 + <span class="dashicons dashicons-arrow-down-alt2"></span>
2209 + </button>
2210 + <?php endif; ?>
2211 + </div>
2212 + <div class="mxchat-content-full" style="display: none;">
2213 + <div class="content-view">
2214 + <?php
2215 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2216 + echo '<div dir="rtl" lang="he" class="rtl-content">';
2217 + echo wp_kses_post(wpautop($content));
2218 + echo '</div>';
2219 + } else {
2220 + echo wp_kses_post(wpautop($content));
2221 + }
2222 + ?>
2223 + </div>
2224 + </div>
2225 + </div>
2226 + </td>
2227 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2228 + <?php
2229 + $actual_source = $source_url;
2230 + if (strpos($source_url, '_ungrouped_') === 0) {
2231 + $actual_source = $prompt->source_url ?? '';
2232 + }
2233 + if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2234 + <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2235 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2236 + <?php esc_html_e('View', 'mxchat'); ?>
2237 + </a>
2238 + <?php else : ?>
2239 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2240 + <?php endif; ?>
2241 + </td>
2242 + <td style="padding: 12px 16px;">
2243 + <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);">
2244 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2245 + </button>
2246 + </td>
2247 + </tr>
2248 + <?php
2249 + }
2250 + }
2251 + }
2252 + $html = ob_get_clean();
2253 +
2254 + // Generate pagination HTML (include search/filter data for subsequent pages)
2255 + $pagination_html = '';
2256 + if ($total_pages > 1) {
2257 + $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) . '">';
2258 +
2259 + // Previous button
2260 + if ($page > 1) {
2261 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2262 + }
2263 +
2264 + // Page numbers
2265 + $start_page = max(1, $page - 2);
2266 + $end_page = min($total_pages, $page + 2);
2267 +
2268 + if ($start_page > 1) {
2269 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2270 + if ($start_page > 2) {
2271 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2272 + }
2273 + }
2274 +
2275 + for ($i = $start_page; $i <= $end_page; $i++) {
2276 + if ($i == $page) {
2277 + $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2278 + } else {
2279 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2280 + }
2281 + }
2282 +
2283 + if ($end_page < $total_pages) {
2284 + if ($end_page < $total_pages - 1) {
2285 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2286 + }
2287 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2288 + }
2289 +
2290 + // Next button
2291 + if ($page < $total_pages) {
2292 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2293 + }
2294 +
2295 + $pagination_html .= '</div>';
2296 + }
2297 +
2298 + wp_send_json_success(array(
2299 + 'html' => $html,
2300 + 'pagination_html' => $pagination_html,
2301 + 'total_count' => $total_records,
2302 + 'total_pages' => $total_pages,
2303 + 'page' => $page,
2304 + 'per_page' => $per_page,
2305 + 'data_source' => 'wordpress'
2306 + ));
2307 +}
2308 +
2309 +/**
2310 + * AJAX handler to detect available sitemaps on the site
2311 + * Optimized for speed - only checks primary sitemap indexes first
2312 + */
2313 +public function ajax_mxchat_detect_sitemaps() {
2314 + check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
2315 +
2316 + if (!current_user_can('manage_options')) {
2317 + wp_send_json_error(array('message' => 'Unauthorized'));
2318 + return;
2319 + }
2320 +
2321 + $site_url = get_site_url();
2322 + $sitemaps = array();
2323 + $found_index = false;
2324 +
2325 + // Only check the main sitemap index files first (much faster)
2326 + // These are the primary entry points that contain sub-sitemaps
2327 + $primary_indexes = array(
2328 + 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
2329 + 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
2330 + 'sitemap.xml' => 'Standard', // Generic/AIOSEO
2331 + );
2332 +
2333 + foreach ($primary_indexes as $path => $source) {
2334 + $url = trailingslashit($site_url) . $path;
2335 +
2336 + $response = wp_remote_head($url, array(
2337 + 'timeout' => 3, // Short timeout
2338 + 'sslverify' => false,
2339 + 'redirection' => 1
2340 + ));
2341 +
2342 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2343 + // Found a sitemap index - parse it to get sub-sitemaps
2344 + $sub_sitemaps = $this->parse_sitemap_index($url);
2345 + if (!empty($sub_sitemaps)) {
2346 + $sitemaps[] = array(
2347 + 'url' => $url,
2348 + 'type' => 'index',
2349 + 'source' => $source,
2350 + 'sub_sitemaps' => $sub_sitemaps
2351 + );
2352 + $found_index = true;
2353 + // Found a valid index, no need to check others
2354 + break;
2355 + }
2356 + }
2357 + }
2358 +
2359 + // If no sitemap index found, check for standalone sitemaps
2360 + if (!$found_index) {
2361 + $standalone_sitemaps = array(
2362 + 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2363 + 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2364 + );
2365 +
2366 + foreach ($standalone_sitemaps as $path => $info) {
2367 + $url = trailingslashit($site_url) . $path;
2368 +
2369 + $response = wp_remote_head($url, array(
2370 + 'timeout' => 2,
2371 + 'sslverify' => false
2372 + ));
2373 +
2374 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2375 + $sitemaps[] = array(
2376 + 'url' => $url,
2377 + 'type' => $info['type'],
2378 + 'source' => $info['source'],
2379 + 'url_count' => 0 // Skip URL count for speed
2380 + );
2381 + }
2382 + }
2383 + }
2384 +
2385 + wp_send_json_success(array(
2386 + 'sitemaps' => $sitemaps,
2387 + 'site_url' => $site_url
2388 + ));
2389 +}
2390 +
2391 +/**
2392 + * Parse a sitemap index to get sub-sitemaps
2393 + * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
2394 + */
2395 +private function parse_sitemap_index($url) {
2396 + $sub_sitemaps = array();
2397 +
2398 + $response = wp_remote_get($url, array(
2399 + 'timeout' => 5,
2400 + 'sslverify' => false
2401 + ));
2402 +
2403 + if (is_wp_error($response)) {
2404 + return $sub_sitemaps;
2405 + }
2406 +
2407 + $body = wp_remote_retrieve_body($response);
2408 + if (empty($body)) {
2409 + return $sub_sitemaps;
2410 + }
2411 +
2412 + // Suppress XML errors
2413 + libxml_use_internal_errors(true);
2414 + $xml = simplexml_load_string($body);
2415 + libxml_clear_errors();
2416 +
2417 + if ($xml === false) {
2418 + return $sub_sitemaps;
2419 + }
2420 +
2421 + // Check if it's a sitemap index (contains <sitemap> elements)
2422 + if (isset($xml->sitemap)) {
2423 + foreach ($xml->sitemap as $sitemap) {
2424 + $loc = (string) $sitemap->loc;
2425 + if (!empty($loc)) {
2426 + // Try to determine the type from the URL
2427 + $type = 'content';
2428 + if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
2429 + $type = 'taxonomy';
2430 + } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
2431 + $type = 'author';
2432 + }
2433 +
2434 + // Skip URL count - too slow to fetch for each sitemap
2435 + $sub_sitemaps[] = array(
2436 + 'url' => $loc,
2437 + 'type' => $type,
2438 + 'url_count' => 0, // Don't fetch - takes too long
2439 + 'name' => basename(parse_url($loc, PHP_URL_PATH))
2440 + );
2441 + }
2442 + }
2443 + }
2444 +
2445 + return $sub_sitemaps;
2446 +}
2447 +
2448 +/**
2449 + * Get URL count from a sitemap
2450 + */
2451 +private function get_sitemap_url_count($url) {
2452 + $response = wp_remote_get($url, array(
2453 + 'timeout' => 10,
2454 + 'sslverify' => false
2455 + ));
2456 +
2457 + if (is_wp_error($response)) {
2458 + return 0;
2459 + }
2460 +
2461 + $body = wp_remote_retrieve_body($response);
2462 + if (empty($body)) {
2463 + return 0;
2464 + }
2465 +
2466 + // Count <url> or <loc> elements
2467 + $count = preg_match_all('/<url>/i', $body, $matches);
2468 + return $count ?: 0;
2469 +}
2470 +
2471 +/**
2472 + * Get sitemaps declared in robots.txt
2473 + */
2474 +private function get_sitemaps_from_robots($site_url) {
2475 + $sitemaps = array();
2476 + $robots_url = trailingslashit($site_url) . 'robots.txt';
2477 +
2478 + $response = wp_remote_get($robots_url, array(
2479 + 'timeout' => 5,
2480 + 'sslverify' => false
2481 + ));
2482 +
2483 + if (is_wp_error($response)) {
2484 + return $sitemaps;
2485 + }
2486 +
2487 + $body = wp_remote_retrieve_body($response);
2488 + if (empty($body)) {
2489 + return $sitemaps;
2490 + }
2491 +
2492 + // Find Sitemap: declarations
2493 + if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
2494 + foreach ($matches[1] as $sitemap_url) {
2495 + $sitemap_url = trim($sitemap_url);
2496 + if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
2497 + $sitemaps[] = $sitemap_url;
2498 + }
2499 + }
2500 + }
2501 +
2502 + return $sitemaps;
2503 +}
2504 +
2505 +public function mxchat_stop_processing() {
2506 + // Verify permissions
2507 + if (!current_user_can('manage_options')) {
2508 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
2509 + }
2510 +
2511 + // Verify nonce
2512 + check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
2513 +
2514 + global $wpdb;
2515 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2516 +
2517 + // Get active queue IDs
2518 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2519 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2520 +
2521 + // Delete all pending items from active queues
2522 + if ($sitemap_queue_id) {
2523 + $wpdb->delete(
2524 + $table_name,
2525 + array(
2526 + 'queue_id' => $sitemap_queue_id,
2527 + 'status' => 'pending'
2528 + ),
2529 + array('%s', '%s')
2530 + );
2531 +
2532 + delete_transient('mxchat_active_queue_sitemap');
2533 + delete_transient('mxchat_last_sitemap_url');
2534 + }
2535 +
2536 + if ($pdf_queue_id) {
2537 + // Get PDF path before deleting
2538 + $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
2539 +
2540 + $wpdb->delete(
2541 + $table_name,
2542 + array(
2543 + 'queue_id' => $pdf_queue_id,
2544 + 'status' => 'pending'
2545 + ),
2546 + array('%s', '%s')
2547 + );
2548 +
2549 + // Delete PDF file
2550 + if ($pdf_path && file_exists($pdf_path)) {
2551 + wp_delete_file($pdf_path);
2552 + }
2553 +
2554 + delete_transient('mxchat_active_queue_pdf');
2555 + delete_transient('mxchat_last_pdf_url');
2556 + }
2557 +
2558 + // Redirect back with a success message
2559 + set_transient('mxchat_admin_notice_success',
2560 + esc_html__('Processing has been stopped successfully.', 'mxchat'),
2561 + 30
2562 + );
2563 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
2564 + exit;
2565 +}
2566 +
2567 +/**
2568 + * Get content list for processing
2569 + */
2570 +public function ajax_mxchat_get_content_list() {
2571 + // Verify the nonce
2572 + check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
2573 +
2574 + if (!current_user_can('manage_options')) {
2575 + wp_send_json_error(__('Unauthorized access', 'mxchat'));
2576 + }
2577 +
2578 + $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
2579 + $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
2580 + $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
2581 + $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
2582 + $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
2583 + $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
2584 +
2585 + // Build query args
2586 + $args = array(
2587 + 'posts_per_page' => $per_page,
2588 + 'paged' => $page,
2589 + 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
2590 + 'orderby' => 'date',
2591 + 'order' => 'DESC',
2592 + );
2593 +
2594 + // Handle post types - IMPROVED VERSION
2595 + if ($post_type !== 'all') {
2596 + $args['post_type'] = $post_type;
2597 + } else {
2598 + // Get all available post types that might contain content
2599 + $all_post_types = array();
2600 +
2601 + // First get all public post types
2602 + $public_types = get_post_types(array('public' => true), 'names');
2603 + $all_post_types = array_merge($all_post_types, $public_types);
2604 +
2605 + // Add common forum/community post types
2606 + $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
2607 + foreach ($forum_types as $forum_type) {
2608 + if (post_type_exists($forum_type)) {
2609 + $all_post_types[] = $forum_type;
2610 + }
2611 + }
2612 +
2613 + // Add other commonly used post types
2614 + $common_types = array('product', 'job_listing', 'event', 'portfolio');
2615 + foreach ($common_types as $common_type) {
2616 + if (post_type_exists($common_type)) {
2617 + $all_post_types[] = $common_type;
2618 + }
2619 + }
2620 +
2621 + // Remove duplicates and ensure we have at least some post types
2622 + $all_post_types = array_unique($all_post_types);
2623 +
2624 + if (empty($all_post_types)) {
2625 + // Fallback to basic post types
2626 + $all_post_types = array('post', 'page');
2627 + }
2628 +
2629 + $args['post_type'] = $all_post_types;
2630 +
2631 + // Debug logging to see what post types are being queried
2632 + //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
2633 + }
2634 +
2635 + if (!empty($search)) {
2636 + $args['s'] = $search;
2637 + }
2638 +
2639 + // Get processed data from storage
2640 + $processed_data = array();
2641 +
2642 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
2643 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2644 +
2645 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
2646 + // Get fresh data from Pinecone - no caching
2647 + $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
2648 + } else {
2649 + // WordPress DB checking with better URL matching for all post types
2650 + global $wpdb;
2651 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2652 + $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
2653 +
2654 + // Group items by source_url to count chunks
2655 + $url_chunk_counts = array();
2656 + $url_latest_timestamp = array();
2657 + $url_first_id = array();
2658 +
2659 + if (!empty($processed_items)) {
2660 + foreach ($processed_items as $item) {
2661 + $url = $item->source_url;
2662 + if (empty($url)) continue;
2663 +
2664 + // Count chunks per URL
2665 + if (!isset($url_chunk_counts[$url])) {
2666 + $url_chunk_counts[$url] = 0;
2667 + $url_latest_timestamp[$url] = $item->timestamp;
2668 + $url_first_id[$url] = $item->id;
2669 + }
2670 + $url_chunk_counts[$url]++;
2671 +
2672 + // Track latest timestamp
2673 + if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
2674 + $url_latest_timestamp[$url] = $item->timestamp;
2675 + }
2676 + }
2677 +
2678 + // Now build processed_data with chunk counts
2679 + foreach ($url_chunk_counts as $url => $chunk_count) {
2680 + $post_id = $this->mxchat_url_to_post_id_improved($url);
2681 +
2682 + if ($post_id) {
2683 + $processed_data[$post_id] = array(
2684 + 'db_id' => $url_first_id[$url],
2685 + 'timestamp' => $url_latest_timestamp[$url],
2686 + 'url' => $url,
2687 + 'source' => 'wordpress',
2688 + 'chunk_count' => $chunk_count
2689 + );
2690 + }
2691 + }
2692 + }
2693 + }
2694 +
2695 + // Get processed IDs as a simple array for in_array checks
2696 + $processed_ids = array_keys($processed_data);
2697 +
2698 + // Handle processed/unprocessed filter
2699 + if ($processed_filter === 'processed' && !empty($processed_ids)) {
2700 + $args['post__in'] = $processed_ids;
2701 + } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
2702 + $args['post__not_in'] = $processed_ids;
2703 + }
2704 +
2705 + // Run the query
2706 + $query = new WP_Query($args);
2707 + $content_items = array();
2708 +
2709 + if ($query->have_posts()) {
2710 + while ($query->have_posts()) {
2711 + $query->the_post();
2712 + $id = get_the_ID();
2713 + $post_date = get_the_date();
2714 + $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
2715 + $word_count = str_word_count(strip_tags(get_the_content()));
2716 +
2717 + $is_processed = in_array($id, $processed_ids);
2718 + $processed_date = '';
2719 + $db_record_id = 0;
2720 + $data_source = 'none';
2721 +
2722 + if ($is_processed && isset($processed_data[$id])) {
2723 + $item_data = $processed_data[$id];
2724 + $data_source = $item_data['source'];
2725 +
2726 + if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
2727 + // WordPress DB format
2728 + $timestamp = strtotime($item_data['timestamp']);
2729 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
2730 + $db_record_id = $item_data['db_id'];
2731 + } elseif ($data_source === 'pinecone') {
2732 + // Pinecone format
2733 + $processed_date = $item_data['processed_date'];
2734 + $db_record_id = $item_data['db_id'];
2735 + }
2736 + }
2737 +
2738 + // Get chunk count for this item
2739 + $chunk_count = 0;
2740 + if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
2741 + $chunk_count = intval($processed_data[$id]['chunk_count']);
2742 + }
2743 +
2744 + $content_items[] = array(
2745 + 'id' => $id,
2746 + 'title' => get_the_title(),
2747 + 'permalink' => get_permalink(),
2748 + 'date' => $post_date,
2749 + 'type' => get_post_type(),
2750 + 'status' => get_post_status(),
2751 + 'excerpt' => $excerpt,
2752 + 'word_count' => $word_count,
2753 + 'already_processed' => $is_processed,
2754 + 'processed_date' => $processed_date,
2755 + 'db_record_id' => $db_record_id,
2756 + 'data_source' => $data_source,
2757 + 'chunk_count' => $chunk_count
2758 + );
2759 + }
2760 + wp_reset_postdata();
2761 + }
2762 +
2763 + $response = array(
2764 + 'items' => $content_items,
2765 + 'total' => $query->found_posts,
2766 + 'total_pages' => $query->max_num_pages,
2767 + 'current_page' => $page,
2768 + 'processed_count' => count($processed_ids)
2769 + );
2770 +
2771 + wp_send_json_success($response);
2772 + exit;
2773 +}
2774 +
2775 +
2776 +/**
2777 + * This function handles various WooCommerce URL formats and permalink structures
2778 + */
2779 +private function mxchat_url_to_post_id_improved($url) {
2780 + // First try the standard WordPress function
2781 + $post_id = url_to_postid($url);
2782 +
2783 + if ($post_id > 0) {
2784 + return $post_id;
2785 + }
2786 +
2787 + // If that fails, try more aggressive URL matching
2788 + // Remove trailing slashes and query parameters for better matching
2789 + $clean_url = rtrim($url, '/');
2790 + $clean_url = strtok($clean_url, '?'); // Remove query parameters
2791 +
2792 + // Try again with cleaned URL
2793 + $post_id = url_to_postid($clean_url);
2794 + if ($post_id > 0) {
2795 + return $post_id;
2796 + }
2797 +
2798 + // For bbPress forum topics, try extracting slug from URL
2799 + if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
2800 + // Handle bbPress URLs: /forums/topic/topic-name/
2801 + if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
2802 + $topic_slug = $matches[1];
2803 +
2804 + // Look up topic by slug
2805 + $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
2806 + if ($topic) {
2807 + return $topic->ID;
2808 + }
2809 +
2810 + // Alternative method: query by post_name
2811 + global $wpdb;
2812 + $post_id = $wpdb->get_var($wpdb->prepare(
2813 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
2814 + $topic_slug
2815 + ));
2816 +
2817 + if ($post_id) {
2818 + return intval($post_id);
2819 + }
2820 + }
2821 +
2822 + // Handle simpler topic URLs: /topic/topic-name/
2823 + if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
2824 + $topic_slug = $matches[1];
2825 +
2826 + global $wpdb;
2827 + $post_id = $wpdb->get_var($wpdb->prepare(
2828 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
2829 + $topic_slug
2830 + ));
2831 +
2832 + if ($post_id) {
2833 + return intval($post_id);
2834 + }
2835 + }
2836 + }
2837 +
2838 + // For WooCommerce products
2839 + if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
2840 + // Extract product slug from various URL formats
2841 + $product_slug = '';
2842 +
2843 + // Handle pretty permalinks: /product/product-name/
2844 + if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
2845 + $product_slug = $matches[1];
2846 + }
2847 + // Handle query parameters: ?product=product-name
2848 + elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
2849 + $product_slug = $matches[1];
2850 + }
2851 +
2852 + if (!empty($product_slug)) {
2853 + // Look up product by slug
2854 + $product = get_page_by_path($product_slug, OBJECT, 'product');
2855 + if ($product) {
2856 + return $product->ID;
2857 + }
2858 +
2859 + // Alternative method: query by post_name
2860 + global $wpdb;
2861 + $post_id = $wpdb->get_var($wpdb->prepare(
2862 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
2863 + $product_slug
2864 + ));
2865 +
2866 + if ($post_id) {
2867 + return intval($post_id);
2868 + }
2869 + }
2870 + }
2871 +
2872 + // Generic approach: try to extract slug and match against all post types
2873 + $parsed_url = wp_parse_url($clean_url);
2874 + $path = $parsed_url['path'] ?? '';
2875 +
2876 + if (!empty($path)) {
2877 + // Get the last part of the path as potential slug
2878 + $path_parts = array_filter(explode('/', trim($path, '/')));
2879 + $potential_slug = end($path_parts);
2880 +
2881 + if (!empty($potential_slug)) {
2882 + global $wpdb;
2883 +
2884 + // Try to find any post with this slug
2885 + $post_id = $wpdb->get_var($wpdb->prepare(
2886 + "SELECT ID FROM {$wpdb->posts}
2887 + WHERE post_name = %s
2888 + AND post_status IN ('publish', 'closed', 'private')
2889 + AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
2890 + ORDER BY CASE
2891 + WHEN post_type = 'post' THEN 1
2892 + WHEN post_type = 'page' THEN 2
2893 + WHEN post_type = 'topic' THEN 3
2894 + WHEN post_type = 'product' THEN 4
2895 + ELSE 5
2896 + END
2897 + LIMIT 1",
2898 + $potential_slug
2899 + ));
2900 +
2901 + if ($post_id) {
2902 + return intval($post_id);
2903 + }
2904 + }
2905 + }
2906 +
2907 + // ADDITIONAL: Try direct database lookup by URL variations
2908 + global $wpdb;
2909 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2910 +
2911 + // Try variations of the URL (with/without trailing slash, http/https)
2912 + $url_variations = array(
2913 + $url,
2914 + rtrim($url, '/'),
2915 + $url . '/',
2916 + str_replace('http://', 'https://', $url),
2917 + str_replace('https://', 'http://', $url),
2918 + str_replace('http://', 'https://', rtrim($url, '/')),
2919 + str_replace('https://', 'http://', rtrim($url, '/'))
2920 + );
2921 +
2922 + // Remove duplicates
2923 + $url_variations = array_unique($url_variations);
2924 +
2925 + foreach ($url_variations as $variation) {
2926 + $existing_record = $wpdb->get_row($wpdb->prepare(
2927 + "SELECT id, source_url FROM $table_name WHERE source_url = %s",
2928 + $variation
2929 + ));
2930 +
2931 + if ($existing_record) {
2932 + // Try to get post ID from this stored URL
2933 + $stored_post_id = url_to_postid($existing_record->source_url);
2934 + if ($stored_post_id > 0) {
2935 + return $stored_post_id;
2936 + }
2937 + }
2938 + }
2939 +
2940 + return 0; // No match found
2941 +}
2942 +/**
2943 + * Process selected content via AJAX
2944 + */
2945 +public function ajax_mxchat_process_selected_content() {
2946 + // Basic request validation
2947 + if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
2948 + wp_send_json_error('Invalid nonce');
2949 + exit;
2950 + }
2951 +
2952 + if (!current_user_can('manage_options')) {
2953 + wp_send_json_error('Unauthorized access');
2954 + exit;
2955 + }
2956 +
2957 + // Get post IDs - safely parse the array
2958 + $post_ids = array();
2959 + if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
2960 + foreach ($_POST['post_ids'] as $id) {
2961 + $post_ids[] = absint($id);
2962 + }
2963 + }
2964 +
2965 + if (empty($post_ids)) {
2966 + wp_send_json_error('No content selected');
2967 + exit;
2968 + }
2969 +
2970 + // Get bot_id from request
2971 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
2972 +
2973 + // Process only ONE post at a time to avoid request size issues
2974 + $post_id = reset($post_ids);
2975 + $post = get_post($post_id);
2976 +
2977 + if (!$post) {
2978 + wp_send_json_error('Post not found');
2979 + exit;
2980 + }
2981 +
2982 + // Get content including title, short description (for WooCommerce), and main content
2983 + $content = $post->post_title . "\n\n";
2984 +
2985 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
2986 + if (!empty($post->post_excerpt)) {
2987 + // Remove shortcode tags but preserve content inside them
2988 + $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
2989 + $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
2990 + }
2991 +
2992 + // Add main content - remove shortcode tags but preserve content inside them
2993 + $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
2994 + $content .= wp_strip_all_tags($clean_content);
2995 +
2996 + // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
2997 + if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
2998 + $product = wc_get_product($post_id);
2999 +
3000 + if ($product) {
3001 + // Get pricing information
3002 + $regular_price = $product->get_regular_price();
3003 + $sale_price = $product->get_sale_price();
3004 + $price = $product->get_price();
3005 + $sku = $product->get_sku();
3006 +
3007 + // Get currency symbol
3008 + $currency_symbol = get_woocommerce_currency_symbol();
3009 +
3010 + // Add pricing information
3011 + $content .= "\n";
3012 + if (!empty($regular_price)) {
3013 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
3014 + } elseif (!empty($price)) {
3015 + $content .= "Price: " . $currency_symbol . $price . "\n";
3016 + }
3017 +
3018 + if (!empty($sale_price) && $sale_price !== $regular_price) {
3019 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
3020 + }
3021 +
3022 + // Handle variable products - show price range
3023 + if ($product->is_type('variable')) {
3024 + $min_price = $product->get_variation_price('min');
3025 + $max_price = $product->get_variation_price('max');
3026 + if ($min_price !== $max_price) {
3027 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
3028 + }
3029 + }
3030 +
3031 + if (!empty($sku)) {
3032 + $content .= "SKU: " . $sku . "\n";
3033 + }
3034 +
3035 + // Get product categories
3036 + $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
3037 + if (!empty($categories) && !is_wp_error($categories)) {
3038 + $content .= "Categories: " . implode(', ', $categories) . "\n";
3039 + }
3040 + }
3041 +
3042 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
3043 + $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
3044 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
3045 + foreach ($custom_tabs as $tab) {
3046 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3047 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
3048 +
3049 + if (!empty($tab_title) && !empty($tab_content)) {
3050 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3051 + }
3052 + }
3053 + }
3054 +
3055 + // Also check for reusable/saved tabs applied to this product
3056 + $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
3057 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
3058 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
3059 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
3060 + foreach ($applied_saved_tabs as $saved_tab_id) {
3061 + if (isset($saved_tabs[$saved_tab_id])) {
3062 + $tab = $saved_tabs[$saved_tab_id];
3063 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3064 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
3065 +
3066 + if (!empty($tab_title) && !empty($tab_content)) {
3067 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3068 + }
3069 + }
3070 + }
3071 + }
3072 + }
3073 + }
3074 +
3075 + // ADD ACF FIELDS SUPPORT
3076 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3077 + if (!empty($acf_fields)) {
3078 + $acf_content_parts = array();
3079 +
3080 + foreach ($acf_fields as $field_name => $field_value) {
3081 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3082 +
3083 + if (!empty($formatted_value)) {
3084 + $field_label = ucwords(str_replace('_', ' ', $field_name));
3085 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
3086 + }
3087 + }
3088 +
3089 + if (!empty($acf_content_parts)) {
3090 + $content .= "\n\n" . implode("\n", $acf_content_parts);
3091 + }
3092 + }
3093 +
3094 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3095 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
3096 + if (!empty($custom_meta)) {
3097 + $meta_content_parts = array();
3098 +
3099 + foreach ($custom_meta as $meta_key => $meta_value) {
3100 + // Convert meta key to readable label
3101 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
3102 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
3103 + }
3104 +
3105 + if (!empty($meta_content_parts)) {
3106 + $content .= "\n\n" . implode("\n", $meta_content_parts);
3107 + }
3108 + }
3109 +
3110 + // Debug logging for WordPress Import content
3111 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
3112 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
3113 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
3114 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
3115 +
3116 + // Note: Removed 10,000 char limit - chunking now handles large content properly
3117 +
3118 + // Get bot-specific API key
3119 + $bot_options = $this->get_bot_options($bot_id);
3120 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3121 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3122 +
3123 + if (strpos($selected_model, 'voyage') === 0) {
3124 + $api_key = $options['voyage_api_key'] ?? '';
3125 + $provider_name = 'Voyage AI';
3126 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3127 + $api_key = $options['gemini_api_key'] ?? '';
3128 + $provider_name = 'Google Gemini';
3129 + } else {
3130 + $api_key = $options['api_key'] ?? '';
3131 + $provider_name = 'OpenAI';
3132 + }
3133 +
3134 + if (empty($api_key)) {
3135 + MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing');
3136 + wp_send_json_error($provider_name . ' API key not configured');
3137 + exit;
3138 + }
3139 +
3140 + $source_url = get_permalink($post_id);
3141 + $vector_id = md5($source_url); // Vector ID for Pinecone
3142 +
3143 + // Check for existing content in bot-specific storage
3144 + $is_update = false;
3145 +
3146 + // Get bot-specific Pinecone configuration
3147 + $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
3148 + $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
3149 +
3150 + if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
3151 + // Check Pinecone for this bot
3152 + $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
3153 + if (isset($pinecone_data[$post_id])) {
3154 + $is_update = true;
3155 + }
3156 + } else {
3157 + // Check WordPress DB (same as before since it's shared)
3158 + global $wpdb;
3159 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3160 + $existing_record = $wpdb->get_row($wpdb->prepare(
3161 + "SELECT id FROM $table_name WHERE source_url = %s",
3162 + $source_url
3163 + ));
3164 +
3165 + if ($existing_record) {
3166 + $is_update = true;
3167 + }
3168 + }
3169 +
3170 + // UPDATED 2.5.6: Determine content type based on post_type
3171 + $post_type = $post->post_type;
3172 + $content_type = 'content'; // Default fallback
3173 +
3174 + // Map WordPress post types to content types
3175 + switch ($post_type) {
3176 + case 'post':
3177 + $content_type = 'post';
3178 + break;
3179 + case 'page':
3180 + $content_type = 'page';
3181 + break;
3182 + case 'product':
3183 + $content_type = 'product';
3184 + break;
3185 + default:
3186 + // For custom post types, use the post type name
3187 + $content_type = sanitize_key($post_type);
3188 + break;
3189 + }
3190 +
3191 + // Use the centralized utility function with bot_id and content_type
3192 + $result = MxChat_Utils::submit_content_to_db(
3193 + $content,
3194 + $source_url,
3195 + $api_key,
3196 + $vector_id,
3197 + $bot_id,
3198 + $content_type
3199 + );
3200 +
3201 + if (is_wp_error($result)) {
3202 + MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
3203 + wp_send_json_error('Storage failed: ' . $result->get_error_message());
3204 + exit;
3205 + }
3206 +
3207 + // Automatically apply role restriction based on tags
3208 + $this->apply_role_restriction_to_post($post_id, $source_url);
3209 +
3210 + $operation_type = $is_update ? 'update' : 'new';
3211 +
3212 + // Count ACF fields for debugging
3213 + $acf_field_count = count($acf_fields);
3214 +
3215 + // Success response with minimal data
3216 + wp_send_json_success(array(
3217 + 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
3218 + 'post_id' => $post_id,
3219 + 'title' => $post->post_title,
3220 + 'operation_type' => $operation_type,
3221 + 'vector_id' => $vector_id,
3222 + 'acf_fields_found' => $acf_field_count,
3223 + 'content_preview' => substr($content, 0, 100) . '...',
3224 + 'bot_id' => $bot_id
3225 + ));
3226 + exit;
3227 +}
3228 +
3229 +private function apply_role_restriction_to_post($post_id, $source_url) {
3230 + // Get tag-role mappings
3231 + $mappings = get_option('mxchat_tag_role_mappings', array());
3232 +
3233 + if (empty($mappings)) {
3234 + return; // No mappings, leave as public
3235 + }
3236 +
3237 + // Get all tags for the post
3238 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
3239 +
3240 + if (empty($post_tags)) {
3241 + return; // No tags, leave as public
3242 + }
3243 +
3244 + // Determine the highest role restriction based on tags
3245 + $highest_role = 'public';
3246 + $role_hierarchy = array(
3247 + 'public' => 0,
3248 + 'logged_in' => 1,
3249 + 'subscriber' => 2,
3250 + 'contributor' => 3,
3251 + 'author' => 4,
3252 + 'editor' => 5,
3253 + 'administrator' => 6
3254 + );
3255 +
3256 + foreach ($post_tags as $tag_slug) {
3257 + if (isset($mappings[$tag_slug])) {
3258 + $role = $mappings[$tag_slug];
3259 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
3260 + $highest_role = $role;
3261 + }
3262 + }
3263 + }
3264 +
3265 + // If no restricted tags found, return (leave as public)
3266 + if ($highest_role === 'public') {
3267 + return;
3268 + }
3269 +
3270 + // Update the role restriction in the database
3271 + global $wpdb;
3272 +
3273 + // Check if using Pinecone
3274 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3275 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3276 +
3277 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3278 + // Update Pinecone role restriction
3279 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
3280 + $vector_id = md5($source_url);
3281 +
3282 + $wpdb->replace(
3283 + $roles_table,
3284 + array(
3285 + 'vector_id' => $vector_id,
3286 + 'role_restriction' => $highest_role,
3287 + 'updated_at' => current_time('mysql')
3288 + ),
3289 + array('%s', '%s', '%s')
3290 + );
3291 + } else {
3292 + // Update WordPress DB
3293 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3294 +
3295 + $wpdb->update(
3296 + $table_name,
3297 + array('role_restriction' => $highest_role),
3298 + array('source_url' => $source_url),
3299 + array('%s'),
3300 + array('%s')
3301 + );
3302 + }
3303 +}
3304 +
3305 +public function mxchat_get_public_post_types() {
3306 + // Get all public post types
3307 + $post_types = get_post_types(array('public' => true), 'objects');
3308 + $post_type_options = array();
3309 +
3310 + foreach ($post_types as $post_type) {
3311 + $post_type_options[$post_type->name] = $post_type->label;
3312 + }
3313 +
3314 + // Also include common forum/community post types that might not be marked as public
3315 + $additional_types = array(
3316 + 'topic' => 'Forum Topics (bbPress)',
3317 + 'reply' => 'Forum Replies (bbPress)',
3318 + 'forum' => 'Forums (bbPress)',
3319 + 'wpforo_topic' => 'wpForo Topics',
3320 + 'wpforo_post' => 'wpForo Posts'
3321 + );
3322 +
3323 + foreach ($additional_types as $type_name => $type_label) {
3324 + if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
3325 + $post_type_options[$type_name] = $type_label;
3326 + }
3327 + }
3328 +
3329 + return $post_type_options;
3330 +}
3331 +
3332 +/**
3333 + * Retrieves processed content from Pinecone API
3334 + */
3335 +public function mxchat_get_pinecone_processed_content($pinecone_options) {
3336 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3337 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3338 +
3339 + if (empty($api_key) || empty($host)) {
3340 + return array();
3341 + }
3342 +
3343 + $pinecone_data = array();
3344 +
3345 + try {
3346 + // Always get fresh data from Pinecone
3347 + $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
3348 +
3349 + // Method 2: Final fallback - try stats endpoint (if available)
3350 + if (empty($pinecone_data)) {
3351 + $stats_url = "https://{$host}/describe_index_stats";
3352 +
3353 + $response = wp_remote_post($stats_url, array(
3354 + 'headers' => array(
3355 + 'Api-Key' => $api_key,
3356 + 'Content-Type' => 'application/json'
3357 + ),
3358 + 'body' => json_encode(array()),
3359 + 'timeout' => 30
3360 + ));
3361 +
3362 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3363 + $body = wp_remote_retrieve_body($response);
3364 + $stats_data = json_decode($body, true);
3365 + }
3366 + }
3367 +
3368 + } catch (Exception $e) {
3369 + // Log error but return fresh data only
3370 + }
3371 +
3372 + return $pinecone_data;
3373 +}
3374 +public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
3375 + //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
3376 +
3377 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3378 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3379 +
3380 + if (empty($api_key) || empty($host) || empty($vector_ids)) {
3381 + //error_log('DEBUG: Missing parameters for fetch by IDs');
3382 + return array();
3383 + }
3384 +
3385 + try {
3386 + $fetch_url = "https://{$host}/vectors/fetch";
3387 + //error_log('DEBUG: Fetch URL: ' . $fetch_url);
3388 + //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
3389 +
3390 + // Pinecone fetch API allows fetching specific vectors by ID
3391 + $fetch_data = array(
3392 + 'ids' => array_values($vector_ids)
3393 + );
3394 +
3395 + $response = wp_remote_post($fetch_url, array(
3396 + 'headers' => array(
3397 + 'Api-Key' => $api_key,
3398 + 'Content-Type' => 'application/json'
3399 + ),
3400 + 'body' => json_encode($fetch_data),
3401 + 'timeout' => 30
3402 + ));
3403 +
3404 + if (is_wp_error($response)) {
3405 + //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
3406 + return array();
3407 + }
3408 +
3409 + $response_code = wp_remote_retrieve_response_code($response);
3410 + //error_log('DEBUG: Fetch response code: ' . $response_code);
3411 +
3412 + if ($response_code !== 200) {
3413 + $error_body = wp_remote_retrieve_body($response);
3414 + //error_log('DEBUG: Fetch failed with body: ' . $error_body);
3415 + return array();
3416 + }
3417 +
3418 + $body = wp_remote_retrieve_body($response);
3419 + $data = json_decode($body, true);
3420 +
3421 + //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
3422 +
3423 + if (!isset($data['vectors'])) {
3424 + //error_log('DEBUG: No vectors key in response');
3425 + return array();
3426 + }
3427 +
3428 + $processed_data = array();
3429 +
3430 + foreach ($data['vectors'] as $vector_id => $vector_data) {
3431 + $metadata = $vector_data['metadata'] ?? array();
3432 + $source_url = $metadata['source_url'] ?? '';
3433 +
3434 + if (!empty($source_url)) {
3435 + $post_id = url_to_postid($source_url);
3436 + if ($post_id) {
3437 + $created_at = $metadata['created_at'] ?? '';
3438 + $processed_date = 'Recently';
3439 +
3440 + if (!empty($created_at)) {
3441 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
3442 + if ($timestamp) {
3443 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3444 + }
3445 + }
3446 +
3447 + $processed_data[$post_id] = array(
3448 + 'db_id' => $vector_id,
3449 + 'processed_date' => $processed_date,
3450 + 'url' => $source_url,
3451 + 'source' => 'pinecone',
3452 + 'timestamp' => $timestamp ?? current_time('timestamp')
3453 + );
3454 + }
3455 + }
3456 + }
3457 +
3458 + //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
3459 + return $processed_data;
3460 +
3461 + } catch (Exception $e) {
3462 + //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
3463 + return array();
3464 + }
3465 +}
3466 +
3467 +/**
3468 + * Scan Pinecone for processed content
3469 + */
3470 +public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
3471 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3472 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3473 +
3474 + if (empty($api_key) || empty($host)) {
3475 + return array();
3476 + }
3477 +
3478 + try {
3479 + // Use multiple random vectors to get better coverage
3480 + $all_matches = array();
3481 + $seen_ids = array();
3482 +
3483 + // Try 3 different random vectors to get better coverage
3484 + for ($i = 0; $i < 3; $i++) {
3485 + $query_url = "https://{$host}/query";
3486 +
3487 + // Generate a random unit vector instead of zeros
3488 + $random_vector = array();
3489 + for ($j = 0; $j < 1536; $j++) {
3490 + $random_vector[] = (rand(-1000, 1000) / 1000.0);
3491 + }
3492 +
3493 + // Normalize the vector to unit length
3494 + $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
3495 + if ($magnitude > 0) {
3496 + $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
3497 + }
3498 +
3499 + $query_data = array(
3500 + 'includeMetadata' => true,
3501 + 'includeValues' => false,
3502 + 'topK' => 10000,
3503 + 'vector' => $random_vector
3504 + );
3505 +
3506 + $response = wp_remote_post($query_url, array(
3507 + 'headers' => array(
3508 + 'Api-Key' => $api_key,
3509 + 'Content-Type' => 'application/json'
3510 + ),
3511 + 'body' => json_encode($query_data),
3512 + 'timeout' => 30
3513 + ));
3514 +
3515 + if (is_wp_error($response)) {
3516 + continue;
3517 + }
3518 +
3519 + $response_code = wp_remote_retrieve_response_code($response);
3520 +
3521 + if ($response_code !== 200) {
3522 + continue;
3523 + }
3524 +
3525 + $body = wp_remote_retrieve_body($response);
3526 + $data = json_decode($body, true);
3527 +
3528 + if (isset($data['matches'])) {
3529 + foreach ($data['matches'] as $match) {
3530 + $match_id = $match['id'] ?? '';
3531 + if (!empty($match_id) && !isset($seen_ids[$match_id])) {
3532 + $all_matches[] = $match;
3533 + $seen_ids[$match_id] = true;
3534 + }
3535 + }
3536 + }
3537 + }
3538 +
3539 + // Convert matches to processed data format, grouping by URL to count chunks
3540 + $processed_data = array();
3541 + $url_chunk_counts = array();
3542 +
3543 + foreach ($all_matches as $match) {
3544 + $metadata = $match['metadata'] ?? array();
3545 + $source_url = $metadata['source_url'] ?? '';
3546 + $match_id = $match['id'] ?? '';
3547 +
3548 + if (!empty($source_url) && !empty($match_id)) {
3549 + $post_id = url_to_postid($source_url);
3550 + if ($post_id) {
3551 + // Count chunks per post_id
3552 + if (!isset($url_chunk_counts[$post_id])) {
3553 + $url_chunk_counts[$post_id] = 0;
3554 + }
3555 + $url_chunk_counts[$post_id]++;
3556 +
3557 + $created_at = $metadata['created_at'] ?? '';
3558 + $processed_date = 'Recently';
3559 +
3560 + if (!empty($created_at)) {
3561 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
3562 + if ($timestamp) {
3563 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3564 + }
3565 + }
3566 +
3567 + // Only store if not already set, or update with newer timestamp
3568 + if (!isset($processed_data[$post_id]) ||
3569 + ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
3570 + $processed_data[$post_id] = array(
3571 + 'db_id' => $match_id,
3572 + 'processed_date' => $processed_date,
3573 + 'url' => $source_url,
3574 + 'source' => 'pinecone',
3575 + 'timestamp' => $timestamp ?? current_time('timestamp')
3576 + );
3577 + }
3578 + }
3579 + }
3580 + }
3581 +
3582 + // Add chunk counts to processed data
3583 + foreach ($url_chunk_counts as $post_id => $chunk_count) {
3584 + if (isset($processed_data[$post_id])) {
3585 + $processed_data[$post_id]['chunk_count'] = $chunk_count;
3586 + }
3587 + }
3588 +
3589 + return $processed_data;
3590 +
3591 + } catch (Exception $e) {
3592 + return array();
3593 + }
3594 +}
3595 +/**
3596 + * Generate embeddings from input text for MXChat with bot support
3597 + */
3598 +private function mxchat_generate_embedding($text, $bot_id = 'default') {
3599 + // Enable detailed logging for debugging
3600 + //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
3601 + //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
3602 +
3603 + // Get bot-specific options
3604 + $bot_options = $this->get_bot_options($bot_id);
3605 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3606 +
3607 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3608 + //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
3609 +
3610 + // Determine provider and endpoint
3611 + if (strpos($selected_model, 'voyage') === 0) {
3612 + $api_key = $options['voyage_api_key'] ?? '';
3613 + $endpoint = 'https://api.voyageai.com/v1/embeddings';
3614 + $provider_name = 'Voyage AI';
3615 + //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
3616 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3617 + $api_key = $options['gemini_api_key'] ?? '';
3618 + $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
3619 + $provider_name = 'Google Gemini';
3620 + //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
3621 + } else {
3622 + $api_key = $options['api_key'] ?? '';
3623 + $endpoint = 'https://api.openai.com/v1/embeddings';
3624 + $provider_name = 'OpenAI';
3625 + //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
3626 + }
3627 +
3628 + //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
3629 +
3630 + if (empty($api_key)) {
3631 + $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
3632 + //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
3633 + return $error_message;
3634 + }
3635 +
3636 + // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
3637 + $estimated_tokens = ceil(str_word_count($text) / 0.75);
3638 + //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
3639 +
3640 + if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
3641 + //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
3642 + // Consider truncating text here
3643 + }
3644 +
3645 + // Prepare request body based on provider
3646 + if (strpos($selected_model, 'gemini-embedding') === 0) {
3647 + // Gemini API format
3648 + $request_body = array(
3649 + 'model' => 'models/' . $selected_model,
3650 + 'content' => array(
3651 + 'parts' => array(
3652 + array('text' => $text)
3653 + )
3654 + )
3655 + );
3656 +
3657 + // Set output dimensionality to 1536 for consistency with other models
3658 + $request_body['outputDimensionality'] = 1536;
3659 + } else {
3660 + // OpenAI/Voyage API format
3661 + $request_body = array(
3662 + 'model' => $selected_model,
3663 + 'input' => $text
3664 + );
3665 +
3666 + // Add output_dimension for voyage-3-large model
3667 + if ($selected_model === 'voyage-3-large') {
3668 + $request_body['output_dimension'] = 2048;
3669 + }
3670 + }
3671 +
3672 + //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
3673 +
3674 + // Prepare headers based on provider
3675 + if (strpos($selected_model, 'gemini-embedding') === 0) {
3676 + // Gemini uses API key as query parameter
3677 + $endpoint .= '?key=' . $api_key;
3678 + $headers = array(
3679 + 'Content-Type' => 'application/json'
3680 + );
3681 + } else {
3682 + // OpenAI/Voyage use Bearer token
3683 + $headers = array(
3684 + 'Authorization' => 'Bearer ' . $api_key,
3685 + 'Content-Type' => 'application/json'
3686 + );
3687 + }
3688 +
3689 + // Make API request
3690 + //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
3691 + $response = wp_remote_post($endpoint, array(
3692 + 'body' => wp_json_encode($request_body),
3693 + 'headers' => $headers,
3694 + 'timeout' => 60 // Increased timeout for large inputs
3695 + ));
3696 +
3697 + // Handle wp_remote_post errors
3698 + if (is_wp_error($response)) {
3699 + $error_message = $response->get_error_message();
3700 + //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
3701 + return 'Connection error: ' . $error_message;
3702 + }
3703 +
3704 + // Get and check HTTP response code
3705 + $http_code = wp_remote_retrieve_response_code($response);
3706 + //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
3707 +
3708 + if ($http_code !== 200) {
3709 + $error_body = wp_remote_retrieve_body($response);
3710 + //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
3711 +
3712 + // Try to parse error for more details
3713 + $error_json = json_decode($error_body, true);
3714 + if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
3715 + $error_type = $error_json['error']['type'] ?? 'unknown';
3716 + $error_message = $error_json['error']['message'] ?? 'No message';
3717 + //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
3718 + //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
3719 +
3720 + // Customize error message for common API errors
3721 + if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
3722 + $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
3723 + } elseif ($error_type === 'authentication_error') {
3724 + $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
3725 + }
3726 +
3727 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
3728 + return $error_message;
3729 + }
3730 +
3731 + $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
3732 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
3733 + return $error_message;
3734 + }
3735 +
3736 + // Parse response body
3737 + $response_body = wp_remote_retrieve_body($response);
3738 + //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
3739 +
3740 + $response_data = json_decode($response_body, true);
3741 +
3742 + if (json_last_error() !== JSON_ERROR_NONE) {
3743 + $error = json_last_error_msg();
3744 + //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
3745 + //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
3746 + return "Failed to parse API response: $error";
3747 + }
3748 +
3749 + // Handle different response formats based on provider
3750 + if (strpos($selected_model, 'gemini-embedding') === 0) {
3751 + // Gemini API response format
3752 + if (isset($response_data['embedding']['values'])) {
3753 + $embedding_dimensions = count($response_data['embedding']['values']);
3754 + //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
3755 +
3756 + // Check if embedding dimensions are as expected (should be 1536)
3757 + if ($embedding_dimensions !== 1536) {
3758 + //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
3759 + }
3760 +
3761 + return $response_data['embedding']['values'];
3762 + } else {
3763 + //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
3764 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
3765 +
3766 + if (isset($response_data['error'])) {
3767 + $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
3768 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3769 + return $error_message;
3770 + }
3771 +
3772 + $error_message = "Invalid Gemini API response format: No embedding found";
3773 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3774 + return $error_message;
3775 + }
3776 + } else {
3777 + // OpenAI/Voyage API response format
3778 + if (isset($response_data['data'][0]['embedding'])) {
3779 + $embedding_dimensions = count($response_data['data'][0]['embedding']);
3780 + //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
3781 +
3782 + // Check if embedding dimensions are as expected
3783 + if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
3784 + ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
3785 + //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
3786 + }
3787 +
3788 + return $response_data['data'][0]['embedding'];
3789 + } else {
3790 + //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
3791 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
3792 +
3793 + if (isset($response_data['error'])) {
3794 + $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
3795 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3796 + return $error_message;
3797 + }
3798 +
3799 + $error_message = "Invalid API response format: No embedding found";
3800 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3801 + return $error_message;
3802 + }
3803 + }
3804 +}
3805 +
3806 +/**
3807 + * Get bot-specific options for multi-bot functionality
3808 + * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
3809 + */
3810 +private function get_bot_options($bot_id = 'default') {
3811 + //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
3812 +
3813 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
3814 + //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
3815 + return array();
3816 + }
3817 +
3818 + $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
3819 +
3820 + if (!empty($bot_options)) {
3821 + //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
3822 + if (isset($bot_options['similarity_threshold'])) {
3823 + //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
3824 + }
3825 + }
3826 +
3827 + return is_array($bot_options) ? $bot_options : array();
3828 +}
3829 +
3830 +/**
3831 + * Get bot-specific Pinecone configuration
3832 + * Used in the knowledge retrieval functions
3833 + */
3834 +// Also add debugging to your get_bot_pinecone_config function
3835 +private function get_bot_pinecone_config($bot_id = 'default') {
3836 + //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
3837 +
3838 + // If default bot or multi-bot add-on not active, use default Pinecone config
3839 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
3840 + //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
3841 + $addon_options = get_option('mxchat_pinecone_addon_options', array());
3842 + $config = array(
3843 + 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
3844 + 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
3845 + 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
3846 + 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
3847 + );
3848 + //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
3849 + return $config;
3850 + }
3851 +
3852 + //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
3853 +
3854 + // Hook for multi-bot add-on to provide bot-specific Pinecone config
3855 + $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
3856 +
3857 + if (!empty($bot_pinecone_config)) {
3858 + //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
3859 + //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
3860 + //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
3861 + //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
3862 + } else {
3863 + //error_log("MXCHAT DEBUG: Filter returned empty config!");
3864 + }
3865 +
3866 + return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
3867 +}
3868 +
3869 +
3870 +public function mxchat_ajax_dismiss_completed_status() {
3871 + try {
3872 + // Verify the request
3873 + check_ajax_referer('mxchat_status_nonce', 'nonce');
3874 +
3875 + if (!current_user_can('manage_options')) {
3876 + wp_send_json_error('Unauthorized access');
3877 + exit;
3878 + }
3879 +
3880 + $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
3881 +
3882 + if ($card_type === 'pdf') {
3883 + // Clear PDF status
3884 + $pdf_url = get_transient('mxchat_last_pdf_url');
3885 + if ($pdf_url) {
3886 + delete_transient('mxchat_pdf_status_' . md5($pdf_url));
3887 + delete_transient('mxchat_last_pdf_url');
3888 + }
3889 + } elseif ($card_type === 'sitemap') {
3890 + // Clear sitemap status
3891 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
3892 + if ($sitemap_url) {
3893 + delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
3894 + delete_transient('mxchat_last_sitemap_url');
3895 + }
3896 + }
3897 +
3898 + wp_send_json_success(array('message' => 'Status dismissed successfully'));
3899 +
3900 + } catch (Exception $e) {
3901 + wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
3902 + }
3903 +}
3904 +
3905 +/**
3906 + * Render completed status cards on page load
3907 + * This ensures completed processing status persists through page refreshes
3908 + */
3909 +public function mxchat_render_completed_status_cards() {
3910 + $output = '';
3911 +
3912 + // Check for completed PDF status
3913 + $pdf_url = get_transient('mxchat_last_pdf_url');
3914 + if ($pdf_url) {
3915 + $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
3916 + if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
3917 + $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
3918 + }
3919 + }
3920 +
3921 + // Check for completed sitemap status
3922 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
3923 + if ($sitemap_url) {
3924 + $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
3925 + if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
3926 + $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
3927 + }
3928 + }
3929 +
3930 + return $output;
3931 +}
3932 +
3933 +/**
3934 + * Render PDF status card HTML
3935 + */
3936 +private function mxchat_render_pdf_status_card($status, $pdf_url) {
3937 + $html = '<div class="mxchat-status-card" data-card-type="pdf">';
3938 + $html .= '<div class="mxchat-status-header">';
3939 + $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
3940 +
3941 + // Add dismiss button for completed status
3942 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
3943 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
3944 + }
3945 +
3946 + // Process Batch button for processing status
3947 + if ($status['status'] === 'processing') {
3948 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
3949 + data-process-type="pdf"
3950 + data-url="' . esc_attr($pdf_url) . '">
3951 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
3952 + }
3953 +
3954 + // Add status badges
3955 + if ($status['status'] === 'error') {
3956 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
3957 + } elseif ($status['status'] === 'complete') {
3958 + if ($status['failed_pages'] > 0) {
3959 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
3960 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
3961 + } else {
3962 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
3963 + }
3964 + }
3965 +
3966 + $html .= '</div>'; // End header
3967 +
3968 + // Progress bar
3969 + $html .= '<div class="mxchat-progress-bar">';
3970 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
3971 + $html .= '</div>';
3972 +
3973 + // Status details
3974 + $html .= '<div class="mxchat-status-details">';
3975 + $html .= '<p>' . sprintf(
3976 + esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
3977 + $status['processed_pages'],
3978 + $status['total_pages'],
3979 + $status['percentage']
3980 + ) . '</p>';
3981 +
3982 + // Show failed pages count if any
3983 + if ($status['failed_pages'] > 0) {
3984 + $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
3985 + }
3986 +
3987 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
3988 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
3989 +
3990 + // Add completion summary if available AND it's an array
3991 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
3992 + $summary = $status['completion_summary'];
3993 + $html .= '<div class="mxchat-completion-summary">';
3994 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
3995 + $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
3996 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
3997 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
3998 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
3999 + $html .= '</div>';
4000 + }
4001 +
4002 + // Add failed pages list if any AND it's an array
4003 + if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
4004 + $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
4005 + }
4006 +
4007 + // Add error message if any
4008 + if (isset($status['error']) && !empty($status['error'])) {
4009 + $html .= '<div class="mxchat-error-notice">';
4010 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4011 + $html .= '</div>';
4012 + }
4013 +
4014 + $html .= '</div>'; // End details
4015 + $html .= '</div>'; // End card
4016 +
4017 + return $html;
4018 +}
4019 +/**
4020 + * Render sitemap status card HTML
4021 + */
4022 +private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
4023 + $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
4024 + $html .= '<div class="mxchat-status-header">';
4025 + $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
4026 +
4027 + // Add dismiss button for completed status
4028 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
4029 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4030 + }
4031 +
4032 + // Process Batch button for processing status
4033 + if ($status['status'] === 'processing') {
4034 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
4035 + data-process-type="sitemap"
4036 + data-url="' . esc_attr($sitemap_url) . '">
4037 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4038 + }
4039 +
4040 + // Add status badges
4041 + if ($status['status'] === 'error') {
4042 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4043 + } elseif ($status['status'] === 'complete') {
4044 + if ($status['failed_urls'] > 0) {
4045 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4046 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
4047 + } else {
4048 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4049 + }
4050 + }
4051 +
4052 + $html .= '</div>'; // End header
4053 +
4054 + // Progress bar
4055 + $html .= '<div class="mxchat-progress-bar">';
4056 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4057 + $html .= '</div>';
4058 +
4059 + // Status details
4060 + $html .= '<div class="mxchat-status-details">';
4061 + $html .= '<p>' . sprintf(
4062 + esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
4063 + $status['processed_urls'],
4064 + $status['total_urls'],
4065 + $status['percentage']
4066 + ) . '</p>';
4067 +
4068 + // Show failed URLs count if any
4069 + if ($status['failed_urls'] > 0) {
4070 + $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
4071 + }
4072 +
4073 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4074 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4075 +
4076 + // Add completion summary if available AND it's an array
4077 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4078 + $summary = $status['completion_summary'];
4079 + $html .= '<div class="mxchat-completion-summary">';
4080 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4081 + $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
4082 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
4083 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
4084 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4085 + $html .= '</div>';
4086 + }
4087 +
4088 + // Add error messages if any (but not the failed URLs list)
4089 + if (!empty($status['error']) || !empty($status['last_error'])) {
4090 + $html .= '<div class="mxchat-error-notice">';
4091 +
4092 + if (!empty($status['error'])) {
4093 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4094 + }
4095 +
4096 + if (!empty($status['last_error'])) {
4097 + $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
4098 + }
4099 +
4100 + $html .= '</div>';
4101 + }
4102 +
4103 + $html .= '</div>'; // End details
4104 + $html .= '</div>'; // End card
4105 +
4106 + return $html;
4107 +}
4108 +
4109 +
4110 +/**
4111 + * Render failed pages list
4112 + */
4113 +private function mxchat_render_failed_pages_list($failed_pages_list) {
4114 + // Validate that $failed_pages_list is an array and not empty
4115 + if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
4116 + return '';
4117 + }
4118 +
4119 + $html = '<div class="mxchat-error-notice">';
4120 + $html .= '<div class="mxchat-failed-pages-container">';
4121 + $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
4122 + $html .= '<details>';
4123 + $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
4124 + $html .= '<div class="mxchat-failed-pages-list">';
4125 +
4126 + // Create table for failed pages
4127 + $html .= '<table class="widefat striped">';
4128 + $html .= '<thead><tr>';
4129 + $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
4130 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4131 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4132 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4133 + $html .= '</tr></thead><tbody>';
4134 +
4135 + // Sort failed pages by most recent
4136 + $sorted_failed_pages = $failed_pages_list;
4137 + usort($sorted_failed_pages, function($a, $b) {
4138 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4139 + });
4140 +
4141 + foreach ($sorted_failed_pages as $item) {
4142 + // Ensure $item is an array before accessing its elements
4143 + if (!is_array($item)) {
4144 + continue;
4145 + }
4146 +
4147 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4148 + $html .= '<tr>';
4149 + $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
4150 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4151 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4152 + $html .= '<td>' . esc_html($time_ago) . '</td>';
4153 + $html .= '</tr>';
4154 + }
4155 +
4156 + $html .= '</tbody></table>';
4157 + $html .= '</div></details></div></div>';
4158 +
4159 + return $html;
4160 +}
4161 +
4162 +/**
4163 + * Render failed URLs list
4164 + */
4165 +private function mxchat_render_failed_urls_list($failed_urls_list) {
4166 + // Validate that $failed_urls_list is an array and not empty
4167 + if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
4168 + return '';
4169 + }
4170 +
4171 + $html = '<div class="mxchat-failed-urls-container">';
4172 + $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
4173 + $html .= '<details>';
4174 + $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
4175 + $html .= '<div class="mxchat-failed-urls-list">';
4176 +
4177 + // Create table for failed URLs
4178 + $html .= '<table class="widefat striped">';
4179 + $html .= '<thead><tr>';
4180 + $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
4181 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4182 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4183 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4184 + $html .= '</tr></thead><tbody>';
4185 +
4186 + // Sort failed URLs by most recent
4187 + $sorted_failed_urls = $failed_urls_list;
4188 + usort($sorted_failed_urls, function($a, $b) {
4189 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4190 + });
4191 +
4192 + // Show up to 50 failed URLs
4193 + $display_urls = array_slice($sorted_failed_urls, 0, 50);
4194 +
4195 + foreach ($display_urls as $item) {
4196 + // Ensure $item is an array before accessing its elements
4197 + if (!is_array($item)) {
4198 + continue;
4199 + }
4200 +
4201 + $url = $item['url'] ?? '';
4202 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4203 +
4204 + // Truncate URL for display
4205 + $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
4206 +
4207 + $html .= '<tr>';
4208 + $html .= '<td style="word-break: break-all;">';
4209 + if (!empty($url)) {
4210 + $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
4211 + } else {
4212 + $html .= esc_html__('Unknown URL', 'mxchat');
4213 + }
4214 + $html .= '</td>';
4215 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4216 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4217 + $html .= '<td>' . esc_html($time_ago) . '</td>';
4218 + $html .= '</tr>';
4219 + }
4220 +
4221 + $html .= '</tbody></table>';
4222 +
4223 + if (count($failed_urls_list) > 50) {
4224 + $html .= '<div class="mxchat-failed-urls-more">+ ' .
4225 + (count($failed_urls_list) - 50) .
4226 + ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
4227 + }
4228 +
4229 + $html .= '</div></details></div>';
4230 +
4231 + return $html;
4232 +}
4233 +
4234 +/**
4235 + * Get all ACF fields for a specific post, excluding any fields the user has disabled
4236 + */
4237 +public function mxchat_get_acf_fields_for_post($post_id) {
4238 + if (!function_exists('get_fields')) {
4239 + return array();
4240 + }
4241 +
4242 + $fields = get_fields($post_id);
4243 + if (!$fields || !is_array($fields)) {
4244 + return array();
4245 + }
4246 +
4247 + // Get excluded fields from settings
4248 + $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
4249 + if (!empty($excluded_fields) && is_array($excluded_fields)) {
4250 + foreach ($excluded_fields as $excluded_field) {
4251 + if (isset($fields[$excluded_field])) {
4252 + unset($fields[$excluded_field]);
4253 + }
4254 + }
4255 + }
4256 +
4257 + return $fields;
4258 +}
4259 +
4260 +/**
4261 + * Get all registered ACF field groups and their fields for the settings UI
4262 + */
4263 +public function mxchat_get_all_acf_fields() {
4264 + if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
4265 + return array();
4266 + }
4267 +
4268 + $all_fields = array();
4269 + $field_groups = acf_get_field_groups();
4270 +
4271 + if (!empty($field_groups)) {
4272 + foreach ($field_groups as $group) {
4273 + $group_fields = acf_get_fields($group['key']);
4274 + if (!empty($group_fields)) {
4275 + $all_fields[$group['title']] = array();
4276 + foreach ($group_fields as $field) {
4277 + $all_fields[$group['title']][] = array(
4278 + 'name' => $field['name'],
4279 + 'label' => $field['label'],
4280 + 'type' => $field['type']
4281 + );
4282 + }
4283 + }
4284 + }
4285 + }
4286 +
4287 + return $all_fields;
4288 +}
4289 +
4290 +/**
4291 + * Get whitelisted custom post meta for a given post
4292 + * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
4293 + */
4294 +public function mxchat_get_whitelisted_post_meta($post_id) {
4295 + $whitelist = get_option('mxchat_custom_meta_whitelist', '');
4296 +
4297 + if (empty($whitelist)) {
4298 + return array();
4299 + }
4300 +
4301 + // Parse the whitelist - one meta key per line
4302 + $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
4303 +
4304 + if (empty($meta_keys)) {
4305 + return array();
4306 + }
4307 +
4308 + $result = array();
4309 +
4310 + foreach ($meta_keys as $key) {
4311 + // Skip empty keys
4312 + if (empty($key)) {
4313 + continue;
4314 + }
4315 +
4316 + $value = get_post_meta($post_id, $key, true);
4317 +
4318 + // Only include non-empty string values
4319 + if (!empty($value) && is_string($value)) {
4320 + $result[$key] = $value;
4321 + } elseif (!empty($value) && is_array($value)) {
4322 + // Handle array values by joining them
4323 + $flat_value = $this->mxchat_flatten_meta_array($value);
4324 + if (!empty($flat_value)) {
4325 + $result[$key] = $flat_value;
4326 + }
4327 + }
4328 + }
4329 +
4330 + return $result;
4331 +}
4332 +
4333 +/**
4334 + * Flatten array meta values into a readable string
4335 + */
4336 +private function mxchat_flatten_meta_array($array, $depth = 0) {
4337 + if ($depth > 3) {
4338 + return ''; // Prevent infinite recursion
4339 + }
4340 +
4341 + $parts = array();
4342 +
4343 + foreach ($array as $key => $value) {
4344 + if (is_string($value) && !empty($value)) {
4345 + $parts[] = $value;
4346 + } elseif (is_array($value)) {
4347 + $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
4348 + if (!empty($nested)) {
4349 + $parts[] = $nested;
4350 + }
4351 + }
4352 + }
4353 +
4354 + return implode(', ', $parts);
4355 +}
4356 +
4357 +/**
4358 + * Format ACF field values for content extraction
4359 + */
4360 +public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
4361 + if (empty($value)) {
4362 + return '';
4363 + }
4364 +
4365 + // Handle WP_Post objects first (THIS IS THE KEY FIX)
4366 + if ($value instanceof WP_Post) {
4367 + return $value->post_title ?: '';
4368 + }
4369 +
4370 + // Handle other WP objects
4371 + if (is_object($value)) {
4372 + if (isset($value->post_title)) {
4373 + return $value->post_title;
4374 + } elseif (isset($value->display_name)) {
4375 + return $value->display_name;
4376 + } elseif (isset($value->name)) {
4377 + return $value->name;
4378 + } elseif (method_exists($value, '__toString')) {
4379 + try {
4380 + return (string) $value;
4381 + } catch (Exception $e) {
4382 + return '';
4383 + }
4384 + }
4385 + // For any other objects, return empty string
4386 + return '';
4387 + }
4388 +
4389 + // Handle different ACF field types
4390 + if (is_array($value)) {
4391 + // Check if it's an image/file field
4392 + if (isset($value['url'])) {
4393 + // Image field - return alt text, title, or caption
4394 + if (!empty($value['alt'])) {
4395 + return $value['alt'];
4396 + } elseif (!empty($value['title'])) {
4397 + return $value['title'];
4398 + } elseif (!empty($value['caption'])) {
4399 + return $value['caption'];
4400 + } else {
4401 + return ''; // Don't include just the URL
4402 + }
4403 + }
4404 +
4405 + // Check if it's a post object or relationship field
4406 + if (isset($value['post_title'])) {
4407 + return $value['post_title'];
4408 + }
4409 +
4410 + // Check if it's a user field
4411 + if (isset($value['display_name'])) {
4412 + return $value['display_name'];
4413 + }
4414 +
4415 + // Check if it's a taxonomy term
4416 + if (isset($value['name']) && isset($value['taxonomy'])) {
4417 + return $value['name'];
4418 + }
4419 +
4420 + // Check if it's a select field with label
4421 + if (isset($value['label'])) {
4422 + return $value['label'];
4423 + }
4424 +
4425 + // Check for repeater field or flexible content
4426 + if (is_numeric(key($value))) {
4427 + $sub_values = array();
4428 + foreach ($value as $sub_item) {
4429 + if (is_array($sub_item)) {
4430 + // For repeater/flexible content, extract text values
4431 + $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
4432 + if (!empty($sub_text)) {
4433 + $sub_values[] = $sub_text;
4434 + }
4435 + } elseif ($sub_item instanceof WP_Post) {
4436 + // Handle WP_Post objects in arrays
4437 + $sub_values[] = $sub_item->post_title ?: '';
4438 + } else {
4439 + $sub_values[] = (string) $sub_item;
4440 + }
4441 + }
4442 + return implode(', ', array_filter($sub_values));
4443 + }
4444 +
4445 + // For other arrays, try to extract meaningful text
4446 + $text_values = array();
4447 + foreach ($value as $key => $val) {
4448 + if (is_string($val) && !empty(trim($val))) {
4449 + $text_values[] = trim($val);
4450 + } elseif ($val instanceof WP_Post) {
4451 + // Handle WP_Post objects in associative arrays
4452 + $text_values[] = $val->post_title ?: '';
4453 + } elseif (is_array($val) && isset($val['post_title'])) {
4454 + $text_values[] = $val['post_title'];
4455 + } elseif (is_array($val) && isset($val['name'])) {
4456 + $text_values[] = $val['name'];
4457 + }
4458 + }
4459 +
4460 + return implode(', ', array_filter($text_values));
4461 + }
4462 +
4463 + // Handle boolean values
4464 + if (is_bool($value)) {
4465 + return $value ? 'Yes' : 'No';
4466 + }
4467 +
4468 + // Handle numeric values
4469 + if (is_numeric($value)) {
4470 + return (string) $value;
4471 + }
4472 +
4473 + // Handle string values
4474 + if (is_string($value)) {
4475 + return trim($value);
4476 + }
4477 +
4478 + // For anything else that we can't handle, return empty string
4479 + // This prevents the "Object could not be converted to string" error
4480 + return '';
4481 +}
4482 +
4483 +/**
4484 + * Extract text from complex ACF array structures
4485 + */
4486 +private function mxchat_extract_text_from_acf_array($array) {
4487 + if (!is_array($array)) {
4488 + return '';
4489 + }
4490 +
4491 + $text_parts = array();
4492 +
4493 + foreach ($array as $key => $value) {
4494 + if (is_string($value) && !empty(trim($value))) {
4495 + // Skip keys that are likely to be IDs or technical values
4496 + if (!is_numeric($value) || strlen($value) > 10) {
4497 + $text_parts[] = trim($value);
4498 + }
4499 + } elseif ($value instanceof WP_Post) {
4500 + // Handle WP_Post objects
4501 + $text_parts[] = $value->post_title ?: '';
4502 + } elseif (is_array($value)) {
4503 + if (isset($value['post_title'])) {
4504 + $text_parts[] = $value['post_title'];
4505 + } elseif (isset($value['name'])) {
4506 + $text_parts[] = $value['name'];
4507 + } elseif (isset($value['label'])) {
4508 + $text_parts[] = $value['label'];
4509 + }
4510 + } elseif (is_object($value)) {
4511 + // Handle other objects safely
4512 + if (isset($value->post_title)) {
4513 + $text_parts[] = $value->post_title;
4514 + } elseif (isset($value->name)) {
4515 + $text_parts[] = $value->name;
4516 + } elseif (isset($value->display_name)) {
4517 + $text_parts[] = $value->display_name;
4518 + }
4519 + }
4520 + }
4521 +
4522 + return implode(', ', array_filter($text_parts));
4523 +}
4524 +
4525 +/**
4526 + * Handle ACF save - fires after ACF fields are saved
4527 + * This ensures ACF field data is available when syncing to knowledge base
4528 + */
4529 +public function mxchat_handle_acf_save($post_id) {
4530 + // Skip if not a valid post
4531 + if (!$post_id || $post_id === 'options') {
4532 + return;
4533 + }
4534 +
4535 + // Skip autosaves and revisions
4536 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
4537 + return;
4538 + }
4539 +
4540 + $post = get_post($post_id);
4541 + if (!$post) {
4542 + return;
4543 + }
4544 +
4545 + $post_type = $post->post_type;
4546 +
4547 + // Check if sync is enabled for this post type
4548 + $should_sync = false;
4549 +
4550 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4551 + $should_sync = true;
4552 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4553 + $should_sync = true;
4554 + } else if ($post_type === 'product' && class_exists('WooCommerce')) {
4555 + // WooCommerce products - check if WooCommerce integration is enabled
4556 + $options = get_option('mxchat_options', array());
4557 + if (isset($options['enable_woocommerce_integration']) &&
4558 + ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
4559 + $should_sync = true;
4560 + }
4561 + } else {
4562 + // Check custom post types
4563 + $option_name = 'mxchat_auto_sync_' . $post_type;
4564 + if (get_option($option_name) === '1') {
4565 + $should_sync = true;
4566 + }
4567 + }
4568 +
4569 + if (!$should_sync) {
4570 + return;
4571 + }
4572 +
4573 + // Only process published posts
4574 + if ($post->post_status !== 'publish') {
4575 + return;
4576 + }
4577 +
4578 + // Check if this post has any ACF fields - if not, no need to re-sync
4579 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4580 + if (empty($acf_fields)) {
4581 + return;
4582 + }
4583 +
4584 + // Use a transient to prevent duplicate processing (post_updated may have already run)
4585 + $transient_key = 'mxchat_acf_synced_' . $post_id;
4586 + if (get_transient($transient_key)) {
4587 + return;
4588 + }
4589 + set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
4590 +
4591 + // Re-run the sync with ACF data now available
4592 + // We pass $update=true since this is effectively an update with ACF data
4593 + $this->mxchat_handle_post_update($post_id, $post, true);
4594 +}
4595 +
4596 +public function mxchat_handle_post_update($post_id, $post, $update) {
4597 + // Basic validation checks
4598 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
4599 + return;
4600 + }
4601 +
4602 + $post_type = $post->post_type;
4603 +
4604 + // Check if sync is enabled for this post type
4605 + $should_sync = false;
4606 +
4607 + // Check built-in post types first
4608 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4609 + $should_sync = true;
4610 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4611 + $should_sync = true;
4612 + } else {
4613 + // Check custom post types
4614 + $option_name = 'mxchat_auto_sync_' . $post_type;
4615 + if (get_option($option_name) === '1') {
4616 + $should_sync = true;
4617 + }
4618 + }
4619 +
4620 + if (!$should_sync) {
4621 + return;
4622 + }
4623 +
4624 + // Check if we have stored the previous status and URL in our transients
4625 + $previous_status_key = 'mxchat_prev_status_' . $post_id;
4626 + $previous_status = get_transient($previous_status_key);
4627 +
4628 + $previous_url_key = 'mxchat_prev_url_' . $post_id;
4629 + $previous_url = get_transient($previous_url_key);
4630 +
4631 + // If the post was previously published but is now not published, remove from knowledge base
4632 + if ($previous_status === 'publish' && $post->post_status !== 'publish') {
4633 + // Use the stored URL from when it was published, or fall back to current permalink
4634 + $source_url = $previous_url ?: get_permalink($post_id);
4635 +
4636 + if ($source_url) {
4637 + // Check if Pinecone is enabled
4638 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4639 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4640 +
4641 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4642 + // Delete from Pinecone
4643 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
4644 + } else {
4645 + // Delete from WordPress DB
4646 + global $wpdb;
4647 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4648 +
4649 + $result = $wpdb->delete(
4650 + $table_name,
4651 + array('source_url' => $source_url),
4652 + array('%s')
4653 + );
4654 + }
4655 + }
4656 +
4657 + // Clean up the transients and exit early
4658 + delete_transient($previous_status_key);
4659 + delete_transient($previous_url_key);
4660 + return;
4661 + }
4662 +
4663 + // Store the current status for next time (if this is an update)
4664 + if ($update) {
4665 + set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
4666 +
4667 + // If the post is currently published, also store its URL
4668 + if ($post->post_status === 'publish') {
4669 + $current_url = get_permalink($post_id);
4670 + set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
4671 + }
4672 + }
4673 +
4674 + // Only process currently published content for adding/updating
4675 + if ($post->post_status === 'publish') {
4676 + // Get the source URL
4677 + $source_url = get_permalink($post_id);
4678 +
4679 + // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
4680 + $title = get_the_title($post_id);
4681 + $content = get_post_field('post_content', $post_id);
4682 + $excerpt = get_post_field('post_excerpt', $post_id);
4683 +
4684 + // Remove shortcode tags but preserve content inside them
4685 + $content = $this->strip_shortcode_tags_preserve_content($content);
4686 + $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
4687 +
4688 + // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
4689 + $content = wp_strip_all_tags($content);
4690 +
4691 + // Combine title, short description (if exists), and content
4692 + $final_content = $title . "\n\n";
4693 +
4694 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
4695 + if (!empty($excerpt)) {
4696 + $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
4697 + }
4698 +
4699 + $final_content .= $content;
4700 +
4701 + // For WooCommerce products, include pricing and product details
4702 + if ($post_type === 'product' && class_exists('WooCommerce')) {
4703 + $product = wc_get_product($post_id);
4704 +
4705 + if ($product) {
4706 + // Get pricing information
4707 + $regular_price = $product->get_regular_price();
4708 + $sale_price = $product->get_sale_price();
4709 + $price = $product->get_price();
4710 + $sku = $product->get_sku();
4711 +
4712 + // Get currency symbol
4713 + $currency_symbol = get_woocommerce_currency_symbol();
4714 +
4715 + // Add pricing information
4716 + $final_content .= "\n";
4717 + if (!empty($regular_price)) {
4718 + $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
4719 + } elseif (!empty($price)) {
4720 + $final_content .= "Price: " . $currency_symbol . $price . "\n";
4721 + }
4722 +
4723 + if (!empty($sale_price) && $sale_price !== $regular_price) {
4724 + $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
4725 + }
4726 +
4727 + // Handle variable products - show price range
4728 + if ($product->is_type('variable')) {
4729 + $min_price = $product->get_variation_price('min');
4730 + $max_price = $product->get_variation_price('max');
4731 + if ($min_price !== $max_price) {
4732 + $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
4733 + }
4734 + }
4735 +
4736 + if (!empty($sku)) {
4737 + $final_content .= "SKU: " . $sku . "\n";
4738 + }
4739 +
4740 + // Get product categories
4741 + $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
4742 + if (!empty($categories) && !is_wp_error($categories)) {
4743 + $final_content .= "Categories: " . implode(', ', $categories) . "\n";
4744 + }
4745 + }
4746 + }
4747 +
4748 + // For custom post types like job_listing, include additional fields
4749 + if ($post_type === 'job_listing') {
4750 + // Add job-specific meta if available
4751 + $job_location = get_post_meta($post_id, '_job_location', true);
4752 + if (!empty($job_location)) {
4753 + $final_content .= "\n\nLocation: " . $job_location;
4754 + }
4755 +
4756 + // Get job type terms
4757 + $job_types = get_the_terms($post_id, 'job_listing_type');
4758 + if (!empty($job_types) && !is_wp_error($job_types)) {
4759 + $types = array();
4760 + foreach ($job_types as $type) {
4761 + $types[] = $type->name;
4762 + }
4763 + $final_content .= "\n\nJob Type: " . implode(', ', $types);
4764 + }
4765 +
4766 + // Get company name if available
4767 + $company_name = get_post_meta($post_id, '_company_name', true);
4768 + if (!empty($company_name)) {
4769 + $final_content .= "\n\nCompany: " . $company_name;
4770 + }
4771 + }
4772 +
4773 + // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
4774 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4775 + if (!empty($acf_fields)) {
4776 + $acf_content_parts = array();
4777 +
4778 + foreach ($acf_fields as $field_name => $field_value) {
4779 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
4780 + if (!empty($formatted_value)) {
4781 + // Convert field name to readable label
4782 + $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
4783 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
4784 + }
4785 + }
4786 +
4787 + if (!empty($acf_content_parts)) {
4788 + $final_content .= "\n\n" . implode("\n", $acf_content_parts);
4789 + }
4790 + }
4791 +
4792 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
4793 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
4794 + if (!empty($custom_meta)) {
4795 + $meta_content_parts = array();
4796 +
4797 + foreach ($custom_meta as $meta_key => $meta_value) {
4798 + // Convert meta key to readable label
4799 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
4800 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
4801 + }
4802 +
4803 + if (!empty($meta_content_parts)) {
4804 + $final_content .= "\n\n" . implode("\n", $meta_content_parts);
4805 + }
4806 + }
4807 +
4808 + // Get API key with proper model detection
4809 + $options = get_option('mxchat_options');
4810 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4811 +
4812 + if (strpos($selected_model, 'voyage') === 0) {
4813 + $api_key = $options['voyage_api_key'] ?? '';
4814 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4815 + $api_key = $options['gemini_api_key'] ?? '';
4816 + } else {
4817 + $api_key = $options['api_key'] ?? '';
4818 + }
4819 +
4820 + if (empty($api_key)) {
4821 + return;
4822 + }
4823 +
4824 + // Use the centralized utility function for storage
4825 + $result = MxChat_Utils::submit_content_to_db(
4826 + $final_content,
4827 + $source_url,
4828 + $api_key,
4829 + md5($source_url) // Vector ID for Pinecone
4830 + );
4831 +
4832 + // After successful storage, apply role restriction based on tags
4833 + if (!is_wp_error($result)) {
4834 + $this->apply_role_restriction_to_post($post_id, $source_url);
4835 + }
4836 + }
4837 +
4838 + // Clean up the stored previous status if not used above
4839 + if ($previous_status !== 'publish' || $post->post_status === 'publish') {
4840 + delete_transient($previous_status_key);
4841 + delete_transient($previous_url_key);
4842 + }
4843 +}
4844 +
4845 +/**
4846 + * Store the post status and URL before update to detect status transitions
4847 + * This runs before the post is actually updated in the database
4848 + */
4849 +public function mxchat_store_pre_update_status($post_id, $data) {
4850 + // Get the current post from database (before update)
4851 + $current_post = get_post($post_id);
4852 +
4853 + if ($current_post) {
4854 + // Store the current status temporarily
4855 + $status_key = 'mxchat_prev_status_' . $post_id;
4856 + set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
4857 +
4858 + // If the post is currently published, also store its URL
4859 + if ($current_post->post_status === 'publish') {
4860 + $url_key = 'mxchat_prev_url_' . $post_id;
4861 + $current_url = get_permalink($post_id);
4862 + set_transient($url_key, $current_url, HOUR_IN_SECONDS);
4863 + }
4864 + }
4865 +}
4866 +
4867 +public function mxchat_handle_post_delete($post_id) {
4868 + // Get post data before it's deleted
4869 + $post = get_post($post_id);
4870 +
4871 + // Basic validation
4872 + if (!$post || wp_is_post_revision($post_id)) {
4873 + return;
4874 + }
4875 +
4876 + $post_type = $post->post_type;
4877 +
4878 + // Check if sync is enabled for this post type
4879 + $should_sync = false;
4880 +
4881 + // Check built-in post types first
4882 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4883 + $should_sync = true;
4884 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4885 + $should_sync = true;
4886 + } else {
4887 + // Check custom post types
4888 + $option_name = 'mxchat_auto_sync_' . $post_type;
4889 + if (get_option($option_name) === '1') {
4890 + $should_sync = true;
4891 + }
4892 + }
4893 +
4894 + if (!$should_sync) {
4895 + return;
4896 + }
4897 +
4898 + // Get the URL before post is deleted
4899 + $source_url = get_permalink($post_id);
4900 + if (!$source_url) {
4901 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
4902 + return;
4903 + }
4904 +
4905 + // Use chunk-aware deletion (handles both chunked and non-chunked content)
4906 + $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
4907 +
4908 + if (is_wp_error($delete_result)) {
4909 + //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
4910 + }
4911 +}
4912 +
4913 +
4914 + /**
4915 + * Deletes data from Pinecone using a source URL
4916 + */
4917 + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
4918 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4919 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4920 +
4921 + if (empty($host) || empty($api_key)) {
4922 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
4923 + return false;
4924 + }
4925 +
4926 + $api_endpoint = "https://{$host}/vectors/delete";
4927 + $vector_id = md5($source_url);
4928 +
4929 + $request_body = array(
4930 + 'ids' => array($vector_id)
4931 + );
4932 +
4933 + $response = wp_remote_post($api_endpoint, array(
4934 + 'headers' => array(
4935 + 'Api-Key' => $api_key,
4936 + 'accept' => 'application/json',
4937 + 'content-type' => 'application/json'
4938 + ),
4939 + 'body' => wp_json_encode($request_body),
4940 + 'timeout' => 30
4941 + ));
4942 +
4943 + if (is_wp_error($response)) {
4944 + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
4945 + return false;
4946 + }
4947 +
4948 + $response_code = wp_remote_retrieve_response_code($response);
4949 + if ($response_code !== 200) {
4950 + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
4951 + return false;
4952 + }
4953 +
4954 + return true;
4955 + }
4956 +
4957 +
4958 +
4959 +public function mxchat_handle_product_change($post_id, $post, $update) {
4960 + if ($post->post_type !== 'product') {
4961 + return;
4962 + }
4963 +
4964 + if ($post->post_status === 'publish') {
4965 + add_action('shutdown', function() use ($post_id) {
4966 + $product = wc_get_product($post_id);
4967 + if ($product) {
4968 + $this->mxchat_store_product_embedding($product);
4969 + }
4970 + });
4971 + }
4972 +}
4973 +
4974 +/**
4975 + * Store WooCommerce product embeddings
4976 + */
4977 +private function mxchat_store_product_embedding($product) {
4978 + if (!isset($this->options['enable_woocommerce_integration']) ||
4979 + !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
4980 + return;
4981 + }
4982 +
4983 + $source_url = get_permalink($product->get_id());
4984 + $product_id = $product->get_id();
4985 +
4986 + // Build product content
4987 + $title = $product->get_name();
4988 + $description = $product->get_description();
4989 + $short_description = $product->get_short_description();
4990 + $regular_price = $product->get_regular_price();
4991 + $sale_price = $product->get_sale_price();
4992 + $price = $product->get_price();
4993 + $sku = $product->get_sku();
4994 +
4995 + // Get currency symbol
4996 + $currency_symbol = get_woocommerce_currency_symbol();
4997 +
4998 + // Format content consistently
4999 + $content = $title . "\n\n";
5000 +
5001 + if (!empty($short_description)) {
5002 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
5003 + }
5004 +
5005 + if (!empty($description)) {
5006 + $content .= wp_strip_all_tags($description) . "\n\n";
5007 + }
5008 +
5009 + // Add pricing information
5010 + if (!empty($regular_price)) {
5011 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
5012 + } elseif (!empty($price)) {
5013 + $content .= "Price: " . $currency_symbol . $price . "\n";
5014 + }
5015 +
5016 + if (!empty($sale_price) && $sale_price !== $regular_price) {
5017 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5018 + }
5019 +
5020 + // Handle variable products - show price range
5021 + if ($product->is_type('variable')) {
5022 + $min_price = $product->get_variation_price('min');
5023 + $max_price = $product->get_variation_price('max');
5024 + if ($min_price !== $max_price) {
5025 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5026 + }
5027 + }
5028 +
5029 + if (!empty($sku)) {
5030 + $content .= "SKU: " . $sku . "\n";
5031 + }
5032 +
5033 + // Get product categories
5034 + $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
5035 + if (!empty($categories) && !is_wp_error($categories)) {
5036 + $content .= "Categories: " . implode(', ', $categories) . "\n";
5037 + }
5038 +
5039 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
5040 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
5041 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
5042 + foreach ($custom_tabs as $tab) {
5043 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5044 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
5045 +
5046 + if (!empty($tab_title) && !empty($tab_content)) {
5047 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5048 + }
5049 + }
5050 + }
5051 +
5052 + // Also check for reusable/saved tabs applied to this product
5053 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
5054 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
5055 + // Get the saved tabs option
5056 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
5057 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
5058 + foreach ($applied_saved_tabs as $saved_tab_id) {
5059 + if (isset($saved_tabs[$saved_tab_id])) {
5060 + $tab = $saved_tabs[$saved_tab_id];
5061 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5062 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
5063 +
5064 + if (!empty($tab_title) && !empty($tab_content)) {
5065 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5066 + }
5067 + }
5068 + }
5069 + }
5070 + }
5071 +
5072 + // Get API key with proper model detection
5073 + $options = get_option('mxchat_options');
5074 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5075 +
5076 + if (strpos($selected_model, 'voyage') === 0) {
5077 + $api_key = $options['voyage_api_key'] ?? '';
5078 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5079 + $api_key = $options['gemini_api_key'] ?? '';
5080 + } else {
5081 + $api_key = $options['api_key'] ?? '';
5082 + }
5083 +
5084 + if (empty($api_key)) {
5085 + //error_log('MxChat Auto-sync: No API key configured for embedding model');
5086 + return;
5087 + }
5088 +
5089 + // Use the centralized utility function for storage
5090 + $result = MxChat_Utils::submit_content_to_db(
5091 + $content,
5092 + $source_url,
5093 + $api_key,
5094 + md5($source_url) // Vector ID for Pinecone
5095 + );
5096 +
5097 + // After successful storage, apply role restriction based on tags
5098 + if (!is_wp_error($result)) {
5099 + $this->apply_role_restriction_to_post($product_id, $source_url);
5100 + }
5101 +
5102 + if (is_wp_error($result)) {
5103 + //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
5104 + }
5105 +}
5106 +
5107 +public function mxchat_handle_product_delete($post_id) {
5108 + if (get_post_type($post_id) !== 'product') {
5109 + return;
5110 + }
5111 +
5112 + $source_url = get_permalink($post_id);
5113 +
5114 + // Check if Pinecone is enabled
5115 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5116 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5117 +
5118 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5119 + // Delete from Pinecone
5120 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5121 + } else {
5122 + // Delete from WordPress DB
5123 + global $wpdb;
5124 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5125 +
5126 + $wpdb->delete(
5127 + $table_name,
5128 + array('source_url' => $source_url),
5129 + array('%s')
5130 + );
5131 + }
5132 +}
5133 +
5134 +/**
5135 + * Handle individual Pinecone content deletion
5136 + */
5137 +public function mxchat_handle_pinecone_prompt_delete() {
5138 + // Check permissions
5139 + if (!current_user_can('manage_options')) {
5140 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
5141 + }
5142 +
5143 + // Verify nonce
5144 + if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
5145 + wp_die(esc_html__('Security check failed.', 'mxchat'));
5146 + }
5147 +
5148 + $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
5149 +
5150 + if (empty($vector_id)) {
5151 + set_transient('mxchat_admin_notice_error',
5152 + esc_html__('Invalid vector ID.', 'mxchat'),
5153 + 30
5154 + );
5155 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5156 + exit;
5157 + }
5158 +
5159 + // Get Pinecone settings
5160 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5161 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5162 +
5163 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5164 + set_transient('mxchat_admin_notice_error',
5165 + esc_html__('Pinecone is not properly configured.', 'mxchat'),
5166 + 30
5167 + );
5168 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5169 + exit;
5170 + }
5171 +
5172 + // Delete from Pinecone
5173 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5174 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5175 + $vector_id,
5176 + $pinecone_options['mxchat_pinecone_api_key'],
5177 + $pinecone_options['mxchat_pinecone_host']
5178 + );
5179 +
5180 + if ($result['success']) {
5181 + // No cache clearing needed since we removed caching
5182 + set_transient('mxchat_admin_notice_success',
5183 + esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
5184 + 30
5185 + );
5186 + } else {
5187 + set_transient('mxchat_admin_notice_error',
5188 + esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
5189 + 30
5190 + );
5191 + }
5192 +
5193 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5194 + exit;
5195 +}
5196 +/**
5197 + * Handle individual Pinecone content deletion via AJAX
5198 + */
5199 +public function ajax_mxchat_delete_pinecone_prompt() {
5200 + // Verify nonce and permissions
5201 + if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
5202 + wp_send_json_error('Invalid nonce');
5203 + exit;
5204 + }
5205 +
5206 + if (!current_user_can('manage_options')) {
5207 + wp_send_json_error('Unauthorized access');
5208 + exit;
5209 + }
5210 +
5211 + $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
5212 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5213 +
5214 + if (empty($vector_id)) {
5215 + wp_send_json_error('Missing vector ID');
5216 + exit;
5217 + }
5218 +
5219 + // Get bot-specific Pinecone settings
5220 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5221 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5222 +
5223 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5224 +
5225 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5226 + wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5227 + exit;
5228 + }
5229 +
5230 + // Delete from the correct Pinecone index
5231 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5232 + $vector_id,
5233 + $pinecone_options['mxchat_pinecone_api_key'],
5234 + $pinecone_options['mxchat_pinecone_host']
5235 + );
5236 +
5237 + if ($result['success']) {
5238 + // No cache clearing needed since we removed caching
5239 + wp_send_json_success(array(
5240 + 'message' => 'Entry deleted successfully from Pinecone',
5241 + 'vector_id' => $vector_id,
5242 + 'bot_id' => $bot_id
5243 + ));
5244 + } else {
5245 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
5246 + wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
5247 + }
5248 +
5249 + exit;
5250 +}
5251 +
5252 +/**
5253 + * Handle deletion of all chunks for a given source URL via AJAX
5254 + * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
5255 + */
5256 +public function ajax_mxchat_delete_chunks_by_url() {
5257 + // Verify nonce and permissions
5258 + if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
5259 + wp_send_json_error('Invalid nonce');
5260 + exit;
5261 + }
5262 +
5263 + if (!current_user_can('manage_options')) {
5264 + wp_send_json_error('Unauthorized access');
5265 + exit;
5266 + }
5267 +
5268 + $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
5269 + $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
5270 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5271 +
5272 + if (empty($source_url)) {
5273 + wp_send_json_error('Missing source URL');
5274 + exit;
5275 + }
5276 +
5277 + // Generate the base vector ID from the source URL (same as how chunks are created)
5278 + $base_vector_id = md5($source_url);
5279 +
5280 + if ($data_source === 'pinecone') {
5281 + // Get bot-specific Pinecone settings (same as working delete function)
5282 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5283 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5284 +
5285 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5286 +
5287 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5288 + wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5289 + exit;
5290 + }
5291 +
5292 + $api_key = $pinecone_options['mxchat_pinecone_api_key'];
5293 + $host = $pinecone_options['mxchat_pinecone_host'];
5294 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
5295 +
5296 + // Collect all vector IDs to delete
5297 + $vectors_to_delete = array();
5298 +
5299 + // Add the original single-vector ID (for non-chunked content)
5300 + $vectors_to_delete[] = $base_vector_id;
5301 +
5302 + // Use Pinecone list API to find all chunk vectors with this prefix
5303 + // NOTE: Pinecone List API is a GET request with query parameters, not POST
5304 + $prefix = $base_vector_id . '_chunk_';
5305 +
5306 + $query_params = array(
5307 + 'prefix' => $prefix,
5308 + 'limit' => 100
5309 + );
5310 +
5311 + if (!empty($namespace)) {
5312 + $query_params['namespace'] = $namespace;
5313 + }
5314 +
5315 + $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
5316 +
5317 + $list_response = wp_remote_get($list_url, array(
5318 + 'headers' => array(
5319 + 'Api-Key' => $api_key,
5320 + 'accept' => 'application/json'
5321 + ),
5322 + 'timeout' => 30
5323 + ));
5324 +
5325 + if (!is_wp_error($list_response)) {
5326 + $list_body_response = wp_remote_retrieve_body($list_response);
5327 + $list_data = json_decode($list_body_response, true);
5328 + if (!empty($list_data['vectors'])) {
5329 + foreach ($list_data['vectors'] as $vector) {
5330 + if (isset($vector['id'])) {
5331 + $vectors_to_delete[] = $vector['id'];
5332 + }
5333 + }
5334 + }
5335 + }
5336 +
5337 + if (empty($vectors_to_delete)) {
5338 + wp_send_json_success(array(
5339 + 'message' => 'No vectors found to delete',
5340 + 'source_url' => $source_url
5341 + ));
5342 + exit;
5343 + }
5344 +
5345 + // Delete all vectors using the same endpoint as the working function
5346 + $delete_url = "https://{$host}/vectors/delete";
5347 +
5348 + $delete_body = array(
5349 + 'ids' => $vectors_to_delete
5350 + );
5351 +
5352 + if (!empty($namespace)) {
5353 + $delete_body['namespace'] = $namespace;
5354 + }
5355 +
5356 + $delete_response = wp_remote_post($delete_url, array(
5357 + 'headers' => array(
5358 + 'Api-Key' => $api_key,
5359 + 'accept' => 'application/json',
5360 + 'content-type' => 'application/json'
5361 + ),
5362 + 'body' => wp_json_encode($delete_body),
5363 + 'timeout' => 30
5364 + ));
5365 +
5366 + if (is_wp_error($delete_response)) {
5367 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
5368 + wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
5369 + exit;
5370 + }
5371 +
5372 + $response_code = wp_remote_retrieve_response_code($delete_response);
5373 +
5374 + if ($response_code !== 200) {
5375 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
5376 + wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
5377 + exit;
5378 + }
5379 +
5380 + wp_send_json_success(array(
5381 + 'message' => 'All chunks deleted successfully from Pinecone',
5382 + 'source_url' => $source_url,
5383 + 'deleted_count' => count($vectors_to_delete)
5384 + ));
5385 +
5386 + } else {
5387 + // WordPress database deletion
5388 + global $wpdb;
5389 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5390 +
5391 + $result = $wpdb->delete(
5392 + $table_name,
5393 + array('source_url' => $source_url),
5394 + array('%s')
5395 + );
5396 +
5397 + if ($result === false) {
5398 + MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
5399 + wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
5400 + exit;
5401 + }
5402 +
5403 + wp_send_json_success(array(
5404 + 'message' => 'All chunks deleted successfully from database',
5405 + 'source_url' => $source_url,
5406 + 'deleted_count' => $result
5407 + ));
5408 + }
5409 +
5410 + exit;
5411 +}
5412 +
5413 +/**
5414 + * Handle individual WordPress database content deletion via AJAX
5415 + * Mirrors the Pinecone delete handler but for WordPress database entries
5416 + */
5417 +public function ajax_mxchat_delete_wordpress_prompt() {
5418 + // Verify nonce and permissions
5419 + if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
5420 + wp_send_json_error('Invalid nonce');
5421 + exit;
5422 + }
5423 +
5424 + if (!current_user_can('manage_options')) {
5425 + wp_send_json_error('Unauthorized access');
5426 + exit;
5427 + }
5428 +
5429 + $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
5430 +
5431 + if (empty($entry_id)) {
5432 + wp_send_json_error('Missing entry ID');
5433 + exit;
5434 + }
5435 +
5436 + global $wpdb;
5437 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5438 +
5439 + // Clear cache for this entry
5440 + wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5441 +
5442 + // Delete from database
5443 + $result = $wpdb->delete(
5444 + $table_name,
5445 + array('id' => $entry_id),
5446 + array('%d')
5447 + );
5448 +
5449 + if ($result !== false) {
5450 + wp_send_json_success(array(
5451 + 'message' => 'Entry deleted successfully',
5452 + 'entry_id' => $entry_id
5453 + ));
5454 + } else {
5455 + wp_send_json_error('Failed to delete entry from database');
5456 + }
5457 +
5458 + exit;
5459 +}
5460 +
5461 +/**
5462 + * Handle bulk deletion of knowledge entries via AJAX
5463 + * Supports both Pinecone and WordPress database entries
5464 + */
5465 +public function ajax_mxchat_bulk_delete_knowledge() {
5466 + // Verify nonce and permissions
5467 + if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
5468 + wp_send_json_error('Invalid nonce');
5469 + exit;
5470 + }
5471 +
5472 + if (!current_user_can('manage_options')) {
5473 + wp_send_json_error('Unauthorized access');
5474 + exit;
5475 + }
5476 +
5477 + $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
5478 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5479 +
5480 + if (empty($entries) || !is_array($entries)) {
5481 + wp_send_json_error('No entries provided');
5482 + exit;
5483 + }
5484 +
5485 + $success_ids = array();
5486 + $failed_ids = array();
5487 + $errors = array();
5488 +
5489 + global $wpdb;
5490 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5491 +
5492 + // Get Pinecone manager for Pinecone deletions
5493 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5494 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5495 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5496 +
5497 + foreach ($entries as $entry) {
5498 + $entry_id = sanitize_text_field($entry['id'] ?? '');
5499 + $source = sanitize_text_field($entry['source'] ?? 'wordpress');
5500 + $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
5501 + $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
5502 +
5503 + if (empty($entry_id)) {
5504 + continue;
5505 + }
5506 +
5507 + try {
5508 + if ($source === 'pinecone') {
5509 + // Handle Pinecone deletion
5510 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5511 + $failed_ids[] = $entry_id;
5512 + $errors[] = "Pinecone not configured for entry: $entry_id";
5513 + continue;
5514 + }
5515 +
5516 + if ($is_group && !empty($source_url)) {
5517 + // Delete all chunks for this URL
5518 + $base_vector_id = md5($source_url);
5519 + $api_key = $pinecone_options['mxchat_pinecone_api_key'];
5520 + $host = $pinecone_options['mxchat_pinecone_host'];
5521 +
5522 + // List all vectors with this prefix
5523 + $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
5524 + $list_response = wp_remote_get($list_url, array(
5525 + 'headers' => array(
5526 + 'Api-Key' => $api_key,
5527 + 'Content-Type' => 'application/json'
5528 + ),
5529 + 'timeout' => 30
5530 + ));
5531 +
5532 + $vector_ids = array($base_vector_id); // Include base ID
5533 +
5534 + if (!is_wp_error($list_response)) {
5535 + $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
5536 + if (isset($list_body['vectors']) && is_array($list_body['vectors'])) {
5537 + foreach ($list_body['vectors'] as $vector) {
5538 + if (isset($vector['id'])) {
5539 + $vector_ids[] = $vector['id'];
5540 + }
5541 + }
5542 + }
5543 + }
5544 +
5545 + // Delete all vectors
5546 + $delete_result = $pinecone_manager->mxchat_delete_pinecone_batch(
5547 + $vector_ids,
5548 + $api_key,
5549 + $host
5550 + );
5551 +
5552 + if ($delete_result['success']) {
5553 + $success_ids[] = $entry_id;
5554 + } else {
5555 + $failed_ids[] = $entry_id;
5556 + $errors[] = $delete_result['message'] ?? "Failed to delete group: $entry_id";
5557 + }
5558 + } else {
5559 + // Delete single vector
5560 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5561 + $entry_id,
5562 + $pinecone_options['mxchat_pinecone_api_key'],
5563 + $pinecone_options['mxchat_pinecone_host']
5564 + );
5565 +
5566 + if ($result['success']) {
5567 + $success_ids[] = $entry_id;
5568 + } else {
5569 + $failed_ids[] = $entry_id;
5570 + $errors[] = $result['message'] ?? "Failed to delete: $entry_id";
5571 + }
5572 + }
5573 + } else {
5574 + // Handle WordPress database deletion
5575 + if ($is_group && !empty($source_url)) {
5576 + // Delete all entries with this source URL
5577 + $result = $wpdb->delete(
5578 + $table_name,
5579 + array('source_url' => $source_url),
5580 + array('%s')
5581 + );
5582 + } else {
5583 + // Delete single entry
5584 + wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5585 + $result = $wpdb->delete(
5586 + $table_name,
5587 + array('id' => intval($entry_id)),
5588 + array('%d')
5589 + );
5590 + }
5591 +
5592 + if ($result !== false) {
5593 + $success_ids[] = $entry_id;
5594 + } else {
5595 + $failed_ids[] = $entry_id;
5596 + $errors[] = "Database error for entry: $entry_id";
5597 + }
5598 + }
5599 + } catch (Exception $e) {
5600 + $failed_ids[] = $entry_id;
5601 + $errors[] = $e->getMessage();
5602 + }
5603 + }
5604 +
5605 + wp_send_json_success(array(
5606 + 'success_ids' => $success_ids,
5607 + 'failed_ids' => $failed_ids,
5608 + 'errors' => $errors,
5609 + 'total_processed' => count($success_ids) + count($failed_ids)
5610 + ));
5611 +
5612 + exit;
5613 +}
5614 +
5615 +/**
5616 + * Get hierarchical roles for dropdown
5617 + */
5618 +public function mxchat_get_role_options() {
5619 + return array(
5620 + 'public' => __('Public (Everyone)', 'mxchat'),
5621 + 'logged_in' => __('Logged In Users', 'mxchat'),
5622 + 'subscriber' => __('Subscribers & Above', 'mxchat'),
5623 + 'contributor' => __('Contributors & Above', 'mxchat'),
5624 + 'author' => __('Authors & Above', 'mxchat'),
5625 + 'editor' => __('Editors & Above', 'mxchat'),
5626 + 'administrator' => __('Administrators Only', 'mxchat')
5627 + );
5628 +}
5629 +
5630 +/**
5631 + * Check if user has access to content based on role restriction
5632 + */
5633 +public function mxchat_user_has_content_access($role_restriction) {
5634 + // Public content is always accessible
5635 + if ($role_restriction === 'public' || empty($role_restriction)) {
5636 + return true;
5637 + }
5638 +
5639 + // Check if user is logged in for logged_in restriction
5640 + if ($role_restriction === 'logged_in') {
5641 + return is_user_logged_in();
5642 + }
5643 +
5644 + // If not logged in, no access to role-restricted content
5645 + if (!is_user_logged_in()) {
5646 + return false;
5647 + }
5648 +
5649 + $user = wp_get_current_user();
5650 + $user_roles = $user->roles;
5651 +
5652 + if (empty($user_roles)) {
5653 + return false;
5654 + }
5655 +
5656 + // Define role hierarchy (higher number = higher access)
5657 + $hierarchy = array(
5658 + 'subscriber' => 1,
5659 + 'contributor' => 2,
5660 + 'author' => 3,
5661 + 'editor' => 4,
5662 + 'administrator' => 5
5663 + );
5664 +
5665 + // Get required level
5666 + $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
5667 +
5668 + // Check if user has required level or higher
5669 + foreach ($user_roles as $user_role) {
5670 + $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
5671 + if ($user_level >= $required_level) {
5672 + return true;
5673 + }
5674 + }
5675 +
5676 + return false;
5677 +}
5678 +
5679 +/**
5680 + * Handle role restriction updates via AJAX
5681 + * Removed cache clearing call since we removed caching
5682 + */
5683 +public function ajax_mxchat_update_role_restriction() {
5684 + // Verify nonce and permissions
5685 + if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
5686 + wp_send_json_error('Invalid nonce');
5687 + exit;
5688 + }
5689 +
5690 + if (!current_user_can('manage_options')) {
5691 + wp_send_json_error('Unauthorized access');
5692 + exit;
5693 + }
5694 +
5695 + $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
5696 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
5697 + $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
5698 +
5699 + if (empty($entry_id)) {
5700 + wp_send_json_error('Invalid entry ID');
5701 + exit;
5702 + }
5703 +
5704 + // Get knowledge manager instance to validate role restriction
5705 + $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5706 + $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
5707 + if (!in_array($role_restriction, $valid_roles)) {
5708 + wp_send_json_error('Invalid role restriction');
5709 + exit;
5710 + }
5711 +
5712 + global $wpdb;
5713 +
5714 + if ($data_source === 'pinecone') {
5715 + // Handle Pinecone role restriction (stored separately in WordPress table)
5716 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5717 +
5718 + // Use REPLACE to insert or update the role restriction
5719 + $result = $wpdb->replace(
5720 + $roles_table,
5721 + array(
5722 + 'vector_id' => $entry_id,
5723 + 'role_restriction' => $role_restriction,
5724 + 'updated_at' => current_time('mysql')
5725 + ),
5726 + array('%s', '%s', '%s')
5727 + );
5728 +
5729 + // No cache clearing needed since we removed caching
5730 +
5731 + } else {
5732 + // Handle WordPress database role restriction (existing functionality)
5733 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5734 +
5735 + $result = $wpdb->update(
5736 + $table_name,
5737 + array('role_restriction' => $role_restriction),
5738 + array('id' => absint($entry_id)),
5739 + array('%s'),
5740 + array('%d')
5741 + );
5742 + }
5743 +
5744 + if ($result === false) {
5745 + wp_send_json_error('Database update failed: ' . $wpdb->last_error);
5746 + exit;
5747 + }
5748 +
5749 + wp_send_json_success(array(
5750 + 'message' => 'Role restriction updated successfully',
5751 + 'role_restriction' => $role_restriction,
5752 + 'data_source' => $data_source,
5753 + 'entry_id' => $entry_id
5754 + ));
5755 + exit;
5756 +}
5757 +
5758 +// ========================================
5759 +// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
5760 +// Add these to your MxChat_Knowledge_Manager class
5761 +// ========================================
5762 +
5763 +/**
5764 + * Initialize role-based content hooks
5765 + * Add this call to your __construct() or mxchat_init_hooks() method
5766 + */
5767 +private function mxchat_init_role_hooks() {
5768 + // AJAX handlers for tag-role mappings
5769 + add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
5770 + add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
5771 + add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
5772 + add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
5773 +
5774 + // Hook to automatically update role restrictions when tags are added/removed
5775 + add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
5776 +
5777 + // Hook to apply role restrictions on auto-sync
5778 + add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
5779 +}
5780 +
5781 +/**
5782 + * Add tag-role mapping via AJAX
5783 + */
5784 +public function ajax_add_tag_role_mapping() {
5785 + // Verify nonce and permissions
5786 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5787 +
5788 + if (!current_user_can('manage_options')) {
5789 + wp_send_json_error('Unauthorized access');
5790 + exit;
5791 + }
5792 +
5793 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
5794 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
5795 +
5796 + if (empty($tag_slug)) {
5797 + wp_send_json_error('Tag slug is required');
5798 + exit;
5799 + }
5800 +
5801 + // Validate role restriction
5802 + $valid_roles = array_keys($this->mxchat_get_role_options());
5803 + if (!in_array($role_restriction, $valid_roles)) {
5804 + wp_send_json_error('Invalid role restriction');
5805 + exit;
5806 + }
5807 +
5808 + // Check if tag exists in WordPress
5809 + $term = get_term_by('slug', $tag_slug, 'post_tag');
5810 + if (!$term) {
5811 + wp_send_json_error('Tag does not exist in WordPress');
5812 + exit;
5813 + }
5814 +
5815 + // Get existing mappings
5816 + $mappings = get_option('mxchat_tag_role_mappings', array());
5817 +
5818 + // Check if mapping already exists
5819 + if (isset($mappings[$tag_slug])) {
5820 + wp_send_json_error('Mapping for this tag already exists');
5821 + exit;
5822 + }
5823 +
5824 + // Add new mapping
5825 + $mappings[$tag_slug] = $role_restriction;
5826 + update_option('mxchat_tag_role_mappings', $mappings);
5827 +
5828 + wp_send_json_success(array(
5829 + 'message' => 'Tag-role mapping added successfully',
5830 + 'tag_slug' => $tag_slug,
5831 + 'role_restriction' => $role_restriction
5832 + ));
5833 + exit;
5834 +}
5835 +
5836 +/**
5837 + * Delete tag-role mapping via AJAX
5838 + */
5839 +public function ajax_delete_tag_role_mapping() {
5840 + // Verify nonce and permissions
5841 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5842 +
5843 + if (!current_user_can('manage_options')) {
5844 + wp_send_json_error('Unauthorized access');
5845 + exit;
5846 + }
5847 +
5848 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
5849 +
5850 + if (empty($tag_slug)) {
5851 + wp_send_json_error('Tag slug is required');
5852 + exit;
5853 + }
5854 +
5855 + // Get existing mappings
5856 + $mappings = get_option('mxchat_tag_role_mappings', array());
5857 +
5858 + // Check if mapping exists
5859 + if (!isset($mappings[$tag_slug])) {
5860 + wp_send_json_error('Mapping does not exist');
5861 + exit;
5862 + }
5863 +
5864 + // Remove mapping
5865 + unset($mappings[$tag_slug]);
5866 + update_option('mxchat_tag_role_mappings', $mappings);
5867 +
5868 + wp_send_json_success(array(
5869 + 'message' => 'Tag-role mapping deleted successfully',
5870 + 'tag_slug' => $tag_slug
5871 + ));
5872 + exit;
5873 +}
5874 +
5875 +/**
5876 + * Get all tag-role mappings via AJAX
5877 + */
5878 +public function ajax_get_tag_role_mappings() {
5879 + // Verify nonce and permissions
5880 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5881 +
5882 + if (!current_user_can('manage_options')) {
5883 + wp_send_json_error('Unauthorized access');
5884 + exit;
5885 + }
5886 +
5887 + // Get mappings
5888 + $mappings = get_option('mxchat_tag_role_mappings', array());
5889 + $role_options = $this->mxchat_get_role_options();
5890 +
5891 + $formatted_mappings = array();
5892 +
5893 + foreach ($mappings as $tag_slug => $role_restriction) {
5894 + // Get tag object
5895 + $term = get_term_by('slug', $tag_slug, 'post_tag');
5896 +
5897 + // Count posts with this tag
5898 + $post_count = 0;
5899 + if ($term) {
5900 + $post_count = $term->count;
5901 + }
5902 +
5903 + $formatted_mappings[] = array(
5904 + 'tag_slug' => $tag_slug,
5905 + 'role_restriction' => $role_restriction,
5906 + 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
5907 + 'post_count' => $post_count
5908 + );
5909 + }
5910 +
5911 + wp_send_json_success(array(
5912 + 'mappings' => $formatted_mappings
5913 + ));
5914 + exit;
5915 +}
5916 +
5917 +/**
5918 + * Bulk update role restrictions for all existing content with mapped tags
5919 + */
5920 +public function ajax_bulk_update_tag_roles() {
5921 + // Verify nonce and permissions
5922 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5923 +
5924 + if (!current_user_can('manage_options')) {
5925 + wp_send_json_error('Unauthorized access');
5926 + exit;
5927 + }
5928 +
5929 + // Get mappings
5930 + $mappings = get_option('mxchat_tag_role_mappings', array());
5931 +
5932 + if (empty($mappings)) {
5933 + wp_send_json_error('No tag-role mappings found');
5934 + exit;
5935 + }
5936 +
5937 + global $wpdb;
5938 +
5939 + // Check if using Pinecone
5940 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5941 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5942 +
5943 + $updated_count = 0;
5944 + $details = array();
5945 +
5946 + foreach ($mappings as $tag_slug => $role_restriction) {
5947 + // Get all posts with this tag
5948 + $posts = get_posts(array(
5949 + 'tag' => $tag_slug,
5950 + 'post_type' => 'any',
5951 + 'posts_per_page' => -1,
5952 + 'fields' => 'ids',
5953 + 'post_status' => 'publish'
5954 + ));
5955 +
5956 + if (empty($posts)) {
5957 + continue;
5958 + }
5959 +
5960 + $tag_updated = 0;
5961 +
5962 + foreach ($posts as $post_id) {
5963 + $source_url = get_permalink($post_id);
5964 + if (!$source_url) {
5965 + continue;
5966 + }
5967 +
5968 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5969 + // Update Pinecone role restriction
5970 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5971 + $vector_id = md5($source_url);
5972 +
5973 + $result = $wpdb->replace(
5974 + $roles_table,
5975 + array(
5976 + 'vector_id' => $vector_id,
5977 + 'role_restriction' => $role_restriction,
5978 + 'updated_at' => current_time('mysql')
5979 + ),
5980 + array('%s', '%s', '%s')
5981 + );
5982 + } else {
5983 + // Update WordPress DB
5984 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5985 +
5986 + $result = $wpdb->update(
5987 + $table_name,
5988 + array('role_restriction' => $role_restriction),
5989 + array('source_url' => $source_url),
5990 + array('%s'),
5991 + array('%s')
5992 + );
5993 + }
5994 +
5995 + if ($result !== false) {
5996 + $tag_updated++;
5997 + $updated_count++;
5998 + }
5999 + }
6000 +
6001 + if ($tag_updated > 0) {
6002 + $details[] = sprintf(
6003 + 'Tag "%s" (%s): %d posts updated',
6004 + $tag_slug,
6005 + $role_restriction,
6006 + $tag_updated
6007 + );
6008 + }
6009 + }
6010 +
6011 + wp_send_json_success(array(
6012 + 'message' => 'Bulk update completed',
6013 + 'updated_count' => $updated_count,
6014 + 'tags_processed' => count($mappings),
6015 + 'details' => $details
6016 + ));
6017 + exit;
6018 +}
6019 +
6020 +/**
6021 + * Handle tag changes on posts (when tags are added or removed)
6022 + */
6023 +public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
6024 + // Only process post tags
6025 + if ($taxonomy !== 'post_tag') {
6026 + return;
6027 + }
6028 +
6029 + // Get tag-role mappings
6030 + $mappings = get_option('mxchat_tag_role_mappings', array());
6031 +
6032 + if (empty($mappings)) {
6033 + return;
6034 + }
6035 +
6036 + // Get the post's URL
6037 + $source_url = get_permalink($object_id);
6038 + if (!$source_url) {
6039 + return;
6040 + }
6041 +
6042 + // Determine the highest role restriction based on tags
6043 + $highest_role = 'public';
6044 + $role_hierarchy = array(
6045 + 'public' => 0,
6046 + 'logged_in' => 1,
6047 + 'subscriber' => 2,
6048 + 'contributor' => 3,
6049 + 'author' => 4,
6050 + 'editor' => 5,
6051 + 'administrator' => 6
6052 + );
6053 +
6054 + // Get all current tags for the post
6055 + $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
6056 +
6057 + // Find the highest role restriction among the tags
6058 + foreach ($current_tags as $tag_slug) {
6059 + if (isset($mappings[$tag_slug])) {
6060 + $role = $mappings[$tag_slug];
6061 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6062 + $highest_role = $role;
6063 + }
6064 + }
6065 + }
6066 +
6067 + // Update the role restriction in the database
6068 + global $wpdb;
6069 +
6070 + // Check if using Pinecone
6071 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6072 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6073 +
6074 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6075 + // Update Pinecone role restriction
6076 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6077 + $vector_id = md5($source_url);
6078 +
6079 + $wpdb->replace(
6080 + $roles_table,
6081 + array(
6082 + 'vector_id' => $vector_id,
6083 + 'role_restriction' => $highest_role,
6084 + 'updated_at' => current_time('mysql')
6085 + ),
6086 + array('%s', '%s', '%s')
6087 + );
6088 + } else {
6089 + // Update WordPress DB
6090 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6091 +
6092 + $wpdb->update(
6093 + $table_name,
6094 + array('role_restriction' => $highest_role),
6095 + array('source_url' => $source_url),
6096 + array('%s'),
6097 + array('%s')
6098 + );
6099 + }
6100 +}
6101 +
6102 +/**
6103 + * Apply role restriction after content is stored (for auto-sync)
6104 + */
6105 +public function apply_role_restriction_after_storage($post_id, $source_url) {
6106 + // Get tag-role mappings
6107 + $mappings = get_option('mxchat_tag_role_mappings', array());
6108 +
6109 + if (empty($mappings)) {
6110 + return;
6111 + }
6112 +
6113 + // Get all tags for the post
6114 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
6115 +
6116 + if (empty($post_tags)) {
6117 + return;
6118 + }
6119 +
6120 + // Determine the highest role restriction based on tags
6121 + $highest_role = 'public';
6122 + $role_hierarchy = array(
6123 + 'public' => 0,
6124 + 'logged_in' => 1,
6125 + 'subscriber' => 2,
6126 + 'contributor' => 3,
6127 + 'author' => 4,
6128 + 'editor' => 5,
6129 + 'administrator' => 6
6130 + );
6131 +
6132 + foreach ($post_tags as $tag_slug) {
6133 + if (isset($mappings[$tag_slug])) {
6134 + $role = $mappings[$tag_slug];
6135 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6136 + $highest_role = $role;
6137 + }
6138 + }
6139 + }
6140 +
6141 + // If no restricted tags found, return (leave as public)
6142 + if ($highest_role === 'public') {
6143 + return;
6144 + }
6145 +
6146 + // Update the role restriction
6147 + global $wpdb;
6148 +
6149 + // Check if using Pinecone
6150 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6151 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6152 +
6153 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6154 + // Update Pinecone role restriction
6155 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6156 + $vector_id = md5($source_url);
6157 +
6158 + $wpdb->replace(
6159 + $roles_table,
6160 + array(
6161 + 'vector_id' => $vector_id,
6162 + 'role_restriction' => $highest_role,
6163 + 'updated_at' => current_time('mysql')
6164 + ),
6165 + array('%s', '%s', '%s')
6166 + );
6167 + } else {
6168 + // Update WordPress DB
6169 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6170 +
6171 + $wpdb->update(
6172 + $table_name,
6173 + array('role_restriction' => $highest_role),
6174 + array('source_url' => $source_url),
6175 + array('%s'),
6176 + array('%s')
6177 + );
6178 + }
6179 +}
6180 +
6181 +
6182 + // ========================================
6183 + // HELPER METHODS
6184 + // ========================================
6185 +
6186 + /**
6187 + * Check if user has required permissions for content processing
6188 + */
6189 + private function mxchat_check_user_permissions() {
6190 + if (!current_user_can('manage_options')) {
6191 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
6192 + }
6193 + }
6194 +
6195 + /**
6196 + * Validate nonce for security
6197 + */
6198 + private function mxchat_validate_nonce($nonce_name, $nonce_action) {
6199 + if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
6200 + wp_die(esc_html__('Security check failed.', 'mxchat'));
6201 + }
6202 + }
6203 +
6204 + /**
6205 + * Get embedding API credentials
6206 + */
6207 + private function mxchat_get_embedding_credentials() {
6208 + $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
6209 +
6210 + if (strpos($embedding_model, 'text-embedding-') !== false) {
6211 + return array(
6212 + 'type' => 'openai',
6213 + 'api_key' => $this->options['api_key'] ?? ''
6214 + );
6215 + } elseif (strpos($embedding_model, 'voyage-') !== false) {
6216 + return array(
6217 + 'type' => 'voyage',
6218 + 'api_key' => $this->options['voyage_api_key'] ?? ''
6219 + );
6220 + } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
6221 + return array(
6222 + 'type' => 'gemini',
6223 + 'api_key' => $this->options['gemini_api_key'] ?? ''
6224 + );
6225 + }
6226 +
6227 + return array('type' => 'unknown', 'api_key' => '');
6228 + }
6229 +
6230 + /**
6231 + * Log processing errors
6232 + */
6233 + private function mxchat_log_processing_error($operation, $error_message) {
6234 + //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
6235 + }
6236 +
6237 + /**
6238 + * Set admin notice transient
6239 + */
6240 + private function mxchat_set_admin_notice($type, $message) {
6241 + set_transient("mxchat_admin_notice_{$type}", $message, 30);
6242 + }
6243 +
6244 + /**
6245 + * Get Pinecone manager instance for vector operations
6246 + */
6247 + private function mxchat_get_pinecone_manager() {
6248 + return MxChat_Pinecone_Manager::get_instance();
6249 + }
6250 +
6251 +
6252 + // ========================================
6253 +// DATABASE QUEUE TABLE MANAGEMENT
6254 +// ========================================
6255 +
6256 +/**
6257 + * Create queue table on plugin activation
6258 + * Call this from your plugin activation hook
6259 + */
6260 +public function mxchat_create_queue_table() {
6261 + global $wpdb;
6262 +
6263 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6264 + $charset_collate = $wpdb->get_charset_collate();
6265 +
6266 + $sql = "CREATE TABLE IF NOT EXISTS $table_name (
6267 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6268 + queue_id varchar(64) NOT NULL,
6269 + item_type varchar(20) NOT NULL,
6270 + item_data longtext NOT NULL,
6271 + status varchar(20) NOT NULL DEFAULT 'pending',
6272 + bot_id varchar(50) NOT NULL DEFAULT 'default',
6273 + priority int(11) NOT NULL DEFAULT 0,
6274 + attempts int(11) NOT NULL DEFAULT 0,
6275 + max_attempts int(11) NOT NULL DEFAULT 3,
6276 + error_message text DEFAULT NULL,
6277 + created_at datetime NOT NULL,
6278 + started_at datetime DEFAULT NULL,
6279 + completed_at datetime DEFAULT NULL,
6280 + PRIMARY KEY (id),
6281 + KEY queue_id (queue_id),
6282 + KEY status (status),
6283 + KEY item_type (item_type),
6284 + KEY priority (priority)
6285 + ) $charset_collate;";
6286 +
6287 + require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
6288 + dbDelta($sql);
6289 +
6290 + // Also create a meta table for queue metadata
6291 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6292 +
6293 + $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
6294 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6295 + queue_id varchar(64) NOT NULL,
6296 + meta_key varchar(255) NOT NULL,
6297 + meta_value longtext,
6298 + PRIMARY KEY (id),
6299 + KEY queue_id (queue_id),
6300 + KEY meta_key (meta_key)
6301 + ) $charset_collate;";
6302 +
6303 + dbDelta($meta_sql);
6304 +}
6305 +
6306 +/**
6307 + * Add items to the processing queue
6308 + *
6309 + * @param string $queue_id Unique identifier for this queue batch
6310 + * @param string $item_type Type of item (url, pdf_page)
6311 + * @param array $items Array of items to queue
6312 + * @param string $bot_id Bot ID for processing
6313 + * @return int Number of items queued
6314 + */
6315 +private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
6316 + global $wpdb;
6317 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6318 +
6319 + $queued_count = 0;
6320 + $priority = 0;
6321 +
6322 + foreach ($items as $item) {
6323 + $result = $wpdb->insert(
6324 + $table_name,
6325 + array(
6326 + 'queue_id' => $queue_id,
6327 + 'item_type' => $item_type,
6328 + 'item_data' => wp_json_encode($item),
6329 + 'status' => 'pending',
6330 + 'bot_id' => $bot_id,
6331 + 'priority' => $priority,
6332 + 'attempts' => 0,
6333 + 'max_attempts' => 3,
6334 + 'created_at' => current_time('mysql')
6335 + ),
6336 + array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
6337 + );
6338 +
6339 + if ($result) {
6340 + $queued_count++;
6341 + }
6342 +
6343 + $priority++; // Process in order
6344 + }
6345 +
6346 + return $queued_count;
6347 +}
6348 +
6349 +/**
6350 + * Store queue metadata (total counts, source URL, etc.)
6351 + */
6352 +private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
6353 + global $wpdb;
6354 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6355 +
6356 + // Check if meta exists
6357 + $existing = $wpdb->get_var($wpdb->prepare(
6358 + "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6359 + $queue_id,
6360 + $meta_key
6361 + ));
6362 +
6363 + if ($existing) {
6364 + // Update
6365 + $wpdb->update(
6366 + $meta_table,
6367 + array('meta_value' => maybe_serialize($meta_value)),
6368 + array('queue_id' => $queue_id, 'meta_key' => $meta_key),
6369 + array('%s'),
6370 + array('%s', '%s')
6371 + );
6372 + } else {
6373 + // Insert
6374 + $wpdb->insert(
6375 + $meta_table,
6376 + array(
6377 + 'queue_id' => $queue_id,
6378 + 'meta_key' => $meta_key,
6379 + 'meta_value' => maybe_serialize($meta_value)
6380 + ),
6381 + array('%s', '%s', '%s')
6382 + );
6383 + }
6384 +}
6385 +
6386 +/**
6387 + * Get queue metadata
6388 + */
6389 +private function mxchat_get_queue_meta($queue_id, $meta_key) {
6390 + global $wpdb;
6391 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6392 +
6393 + $value = $wpdb->get_var($wpdb->prepare(
6394 + "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6395 + $queue_id,
6396 + $meta_key
6397 + ));
6398 +
6399 + return maybe_unserialize($value);
6400 +}
6401 +
6402 +// ========================================
6403 +// AJAX QUEUE PROCESSING HANDLERS
6404 +// ========================================
6405 +
6406 +/**
6407 + * AJAX: Get next item from queue to process
6408 + */
6409 +public function ajax_mxchat_get_next_queue_item() {
6410 + // Verify nonce and permissions
6411 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6412 +
6413 + if (!current_user_can('manage_options')) {
6414 + wp_send_json_error('Unauthorized access');
6415 + }
6416 +
6417 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
6418 +
6419 + if (empty($queue_id)) {
6420 + wp_send_json_error('Missing queue ID');
6421 + }
6422 +
6423 + global $wpdb;
6424 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6425 +
6426 + // Get next pending item with retry logic for failed items
6427 + $next_item = $wpdb->get_row($wpdb->prepare(
6428 + "SELECT * FROM $table_name
6429 + WHERE queue_id = %s
6430 + AND status IN ('pending', 'failed')
6431 + AND attempts < max_attempts
6432 + ORDER BY priority ASC, id ASC
6433 + LIMIT 1",
6434 + $queue_id
6435 + ));
6436 +
6437 + if (!$next_item) {
6438 + // No more items - queue complete
6439 + wp_send_json_success(array(
6440 + 'complete' => true,
6441 + 'message' => 'Queue processing complete'
6442 + ));
6443 + }
6444 +
6445 + // Mark item as processing
6446 + $wpdb->update(
6447 + $table_name,
6448 + array(
6449 + 'status' => 'processing',
6450 + 'started_at' => current_time('mysql'),
6451 + 'attempts' => $next_item->attempts + 1
6452 + ),
6453 + array('id' => $next_item->id),
6454 + array('%s', '%s', '%d'),
6455 + array('%d')
6456 + );
6457 +
6458 + wp_send_json_success(array(
6459 + 'complete' => false,
6460 + 'item' => array(
6461 + 'id' => $next_item->id,
6462 + 'type' => $next_item->item_type,
6463 + 'data' => json_decode($next_item->item_data, true),
6464 + 'bot_id' => $next_item->bot_id,
6465 + 'attempt' => $next_item->attempts + 1
6466 + )
6467 + ));
6468 +}
6469 +
6470 +/**
6471 + * AJAX: Process a single queue item
6472 + */
6473 +public function ajax_mxchat_process_queue_item() {
6474 + // Verify nonce and permissions
6475 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6476 +
6477 + if (!current_user_can('manage_options')) {
6478 + wp_send_json_error('Unauthorized access');
6479 + }
6480 +
6481 + $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
6482 + $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
6483 + $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
6484 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
6485 +
6486 + if (empty($item_id) || empty($item_type)) {
6487 + wp_send_json_error('Missing item data');
6488 + }
6489 +
6490 + global $wpdb;
6491 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6492 +
6493 + // Process based on item type
6494 + try {
6495 + set_time_limit(60); // Give processing 60 seconds
6496 +
6497 + $result = false;
6498 + $error_message = '';
6499 +
6500 + switch ($item_type) {
6501 + case 'url':
6502 + $result = $this->mxchat_process_queue_url($item_data, $bot_id);
6503 + break;
6504 +
6505 + case 'pdf_page':
6506 + $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
6507 + break;
6508 +
6509 + default:
6510 + throw new Exception('Unknown item type: ' . $item_type);
6511 + }
6512 +
6513 + if (is_wp_error($result)) {
6514 + throw new Exception($result->get_error_message());
6515 + }
6516 +
6517 + if ($result === false) {
6518 + throw new Exception('Processing returned false - item may be empty or invalid');
6519 + }
6520 +
6521 + // Mark as completed
6522 + $wpdb->update(
6523 + $table_name,
6524 + array(
6525 + 'status' => 'completed',
6526 + 'completed_at' => current_time('mysql'),
6527 + 'error_message' => null
6528 + ),
6529 + array('id' => $item_id),
6530 + array('%s', '%s', '%s'),
6531 + array('%d')
6532 + );
6533 +
6534 + wp_send_json_success(array(
6535 + 'processed' => true,
6536 + 'item_id' => $item_id,
6537 + 'message' => 'Item processed successfully'
6538 + ));
6539 +
6540 + } catch (Exception $e) {
6541 + $error_message = $e->getMessage();
6542 +
6543 + // Get current attempt count
6544 + $item = $wpdb->get_row($wpdb->prepare(
6545 + "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
6546 + $item_id
6547 + ));
6548 +
6549 + // Check if we've exhausted retries
6550 + if ($item && $item->attempts >= $item->max_attempts) {
6551 + // Permanently failed
6552 + $wpdb->update(
6553 + $table_name,
6554 + array(
6555 + 'status' => 'failed',
6556 + 'error_message' => $error_message
6557 + ),
6558 + array('id' => $item_id),
6559 + array('%s', '%s'),
6560 + array('%d')
6561 + );
6562 +
6563 + wp_send_json_error(array(
6564 + 'message' => 'Item failed after maximum attempts: ' . $error_message,
6565 + 'permanent_failure' => true,
6566 + 'item_id' => $item_id
6567 + ));
6568 + } else {
6569 + // Mark for retry
6570 + $wpdb->update(
6571 + $table_name,
6572 + array(
6573 + 'status' => 'failed',
6574 + 'error_message' => $error_message
6575 + ),
6576 + array('id' => $item_id),
6577 + array('%s', '%s'),
6578 + array('%d')
6579 + );
6580 +
6581 + wp_send_json_error(array(
6582 + 'message' => 'Item processing failed, will retry: ' . $error_message,
6583 + 'can_retry' => true,
6584 + 'item_id' => $item_id,
6585 + 'attempts' => $item ? $item->attempts : 0
6586 + ));
6587 + }
6588 + }
6589 +}
6590 +
6591 +/**
6592 + * Process a URL from the queue
6593 + */
6594 +private function mxchat_process_queue_url($item_data, $bot_id = 'default') {
6595 + $url = isset($item_data['url']) ? $item_data['url'] : '';
6596 +
6597 + if (empty($url)) {
6598 + return new WP_Error('invalid_url', 'URL is empty');
6599 + }
6600 +
6601 + // Get bot-specific API key early (needed for both paths)
6602 + $bot_options = $this->get_bot_options($bot_id);
6603 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6604 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6605 +
6606 + if (strpos($selected_model, 'voyage') === 0) {
6607 + $api_key = $options['voyage_api_key'] ?? '';
6608 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6609 + $api_key = $options['gemini_api_key'] ?? '';
6610 + } else {
6611 + $api_key = $options['api_key'] ?? '';
6612 + }
6613 +
6614 + if (empty($api_key)) {
6615 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
6616 + }
6617 +
6618 + // Check if this is a WooCommerce product URL and WooCommerce is active
6619 + $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
6620 + $content_type = $is_product_url ? 'product' : 'url';
6621 +
6622 + // Try to get WooCommerce product data if it's a product URL
6623 + if ($is_product_url && class_exists('WooCommerce')) {
6624 + $product_content = $this->mxchat_extract_woocommerce_product_content($url);
6625 +
6626 + if (!empty($product_content)) {
6627 + // Successfully extracted WooCommerce product data with pricing
6628 + $result = MxChat_Utils::submit_content_to_db(
6629 + $product_content,
6630 + $url,
6631 + $api_key,
6632 + null,
6633 + $bot_id,
6634 + 'product'
6635 + );
6636 + return $result;
6637 + }
6638 + // If WooCommerce extraction failed, fall through to HTML extraction
6639 + }
6640 +
6641 + // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
6642 + $response = wp_remote_get($url, array(
6643 + 'timeout' => 30,
6644 + 'redirection' => 5,
6645 + 'user-agent' => 'MxChat/1.0'
6646 + ));
6647 +
6648 + if (is_wp_error($response)) {
6649 + return $response;
6650 + }
6651 +
6652 + $response_code = wp_remote_retrieve_response_code($response);
6653 + if ($response_code !== 200) {
6654 + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error');
6655 + }
6656 +
6657 + $html = wp_remote_retrieve_body($response);
6658 +
6659 + if (empty($html)) {
6660 + return new WP_Error('empty_response', 'Empty response body');
6661 + }
6662 +
6663 + // Extract and sanitize content
6664 + $content = $this->mxchat_extract_main_content($html);
6665 + $sanitized = $this->mxchat_sanitize_content_for_api($content);
6666 +
6667 + if (empty($sanitized)) {
6668 + // Not an error - just no content found (maybe a redirect or empty page)
6669 + return false;
6670 + }
6671 +
6672 + // Submit to database with content_type
6673 + $result = MxChat_Utils::submit_content_to_db(
6674 + $sanitized,
6675 + $url,
6676 + $api_key,
6677 + null,
6678 + $bot_id,
6679 + $content_type
6680 + );
6681 +
6682 + return $result;
6683 +}
6684 +
6685 +/**
6686 + * Extract WooCommerce product content including pricing
6687 + *
6688 + * @param string $url The product URL
6689 + * @return string|false Product content with pricing, or false if not found
6690 + */
6691 +private function mxchat_extract_woocommerce_product_content($url) {
6692 + // Try to get product ID from URL
6693 + $product_id = url_to_postid($url);
6694 +
6695 + // If url_to_postid fails, try to extract from URL pattern
6696 + if (!$product_id) {
6697 + $product_slug = '';
6698 +
6699 + // Handle pretty permalinks: /product/product-name/
6700 + if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
6701 + $product_slug = $matches[1];
6702 + }
6703 +
6704 + if (!empty($product_slug)) {
6705 + $product_post = get_page_by_path($product_slug, OBJECT, 'product');
6706 + if ($product_post) {
6707 + $product_id = $product_post->ID;
6708 + }
6709 + }
6710 + }
6711 +
6712 + if (!$product_id) {
6713 + return false;
6714 + }
6715 +
6716 + // Get WooCommerce product object
6717 + $product = wc_get_product($product_id);
6718 +
6719 + if (!$product) {
6720 + return false;
6721 + }
6722 +
6723 + // Build product content with pricing (similar to mxchat_store_product_embedding)
6724 + $title = $product->get_name();
6725 + $description = $product->get_description();
6726 + $short_description = $product->get_short_description();
6727 + $sku = $product->get_sku();
6728 +
6729 + // Get pricing information
6730 + $regular_price = $product->get_regular_price();
6731 + $sale_price = $product->get_sale_price();
6732 + $price = $product->get_price(); // Current active price
6733 +
6734 + // Get currency symbol
6735 + $currency_symbol = get_woocommerce_currency_symbol();
6736 +
6737 + // Format content
6738 + $content = $title . "\n\n";
6739 +
6740 + if (!empty($short_description)) {
6741 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
6742 + }
6743 +
6744 + if (!empty($description)) {
6745 + $content .= wp_strip_all_tags($description) . "\n\n";
6746 + }
6747 +
6748 + // Add pricing information
6749 + if (!empty($regular_price)) {
6750 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
6751 + } elseif (!empty($price)) {
6752 + $content .= "Price: " . $currency_symbol . $price . "\n";
6753 + }
6754 +
6755 + if (!empty($sale_price) && $sale_price !== $regular_price) {
6756 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
6757 + }
6758 +
6759 + // Handle variable products - show price range
6760 + if ($product->is_type('variable')) {
6761 + $min_price = $product->get_variation_price('min');
6762 + $max_price = $product->get_variation_price('max');
6763 + if ($min_price !== $max_price) {
6764 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
6765 + }
6766 + }
6767 +
6768 + if (!empty($sku)) {
6769 + $content .= "SKU: " . $sku . "\n";
6770 + }
6771 +
6772 + // Get product categories
6773 + $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
6774 + if (!empty($categories) && !is_wp_error($categories)) {
6775 + $content .= "Categories: " . implode(', ', $categories) . "\n";
6776 + }
6777 +
6778 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
6779 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
6780 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
6781 + foreach ($custom_tabs as $tab) {
6782 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6783 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
6784 +
6785 + if (!empty($tab_title) && !empty($tab_content)) {
6786 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6787 + }
6788 + }
6789 + }
6790 +
6791 + // Also check for reusable/saved tabs applied to this product
6792 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
6793 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
6794 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
6795 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
6796 + foreach ($applied_saved_tabs as $saved_tab_id) {
6797 + if (isset($saved_tabs[$saved_tab_id])) {
6798 + $tab = $saved_tabs[$saved_tab_id];
6799 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6800 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
6801 +
6802 + if (!empty($tab_title) && !empty($tab_content)) {
6803 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6804 + }
6805 + }
6806 + }
6807 + }
6808 + }
6809 +
6810 + return $this->mxchat_sanitize_content_for_api($content);
6811 +}
6812 +
6813 +/**
6814 + * Process a PDF page from the queue
6815 + */
6816 +private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
6817 + $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
6818 + $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
6819 + $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
6820 + $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
6821 +
6822 + if (empty($pdf_path) || !file_exists($pdf_path)) {
6823 + return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
6824 + }
6825 +
6826 + if ($page_number < 1) {
6827 + return new WP_Error('invalid_page', 'Invalid page number');
6828 + }
6829 +
6830 + try {
6831 + $parser = new \Smalot\PdfParser\Parser();
6832 + $pdf = $parser->parseFile($pdf_path);
6833 + $pages = $pdf->getPages();
6834 +
6835 + if (!isset($pages[$page_number - 1])) {
6836 + return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
6837 + }
6838 +
6839 + $text = $pages[$page_number - 1]->getText();
6840 +
6841 + if (empty($text)) {
6842 + // Not an error - just an empty page
6843 + return false;
6844 + }
6845 +
6846 + $sanitized = $this->mxchat_sanitize_content_for_api($text);
6847 +
6848 + if (empty($sanitized)) {
6849 + return false;
6850 + }
6851 +
6852 + // Create metadata
6853 + $metadata = array(
6854 + 'document_type' => 'pdf',
6855 + 'total_pages' => $total_pages,
6856 + 'current_page' => $page_number,
6857 + 'source_url' => $pdf_url
6858 + );
6859 +
6860 + $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
6861 + $page_url = esc_url($pdf_url . "#page=" . $page_number);
6862 +
6863 + // Get bot-specific API key
6864 + $bot_options = $this->get_bot_options($bot_id);
6865 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6866 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6867 +
6868 + if (strpos($selected_model, 'voyage') === 0) {
6869 + $api_key = $options['voyage_api_key'] ?? '';
6870 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6871 + $api_key = $options['gemini_api_key'] ?? '';
6872 + } else {
6873 + $api_key = $options['api_key'] ?? '';
6874 + }
6875 +
6876 + if (empty($api_key)) {
6877 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
6878 + }
6879 +
6880 + // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
6881 + $result = MxChat_Utils::submit_content_to_db(
6882 + $content_with_metadata,
6883 + $page_url,
6884 + $api_key,
6885 + null,
6886 + $bot_id,
6887 + 'pdf'
6888 + );
6889 +
6890 + return $result;
6891 +
6892 + } catch (Exception $e) {
6893 + return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
6894 + }
6895 +}
6896 +
6897 +/**
6898 + * AJAX: Get queue processing status
6899 + */
6900 +public function ajax_mxchat_get_queue_status() {
6901 + // Verify nonce and permissions
6902 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6903 +
6904 + if (!current_user_can('manage_options')) {
6905 + wp_send_json_error('Unauthorized access');
6906 + }
6907 +
6908 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
6909 +
6910 + if (empty($queue_id)) {
6911 + wp_send_json_error('Missing queue ID');
6912 + }
6913 +
6914 + global $wpdb;
6915 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6916 +
6917 + // Get counts by status
6918 + $counts = $wpdb->get_results($wpdb->prepare(
6919 + "SELECT status, COUNT(*) as count
6920 + FROM $table_name
6921 + WHERE queue_id = %s
6922 + GROUP BY status",
6923 + $queue_id
6924 + ), OBJECT_K);
6925 +
6926 + $total = 0;
6927 + $completed = 0;
6928 + $failed = 0;
6929 + $processing = 0;
6930 + $pending = 0;
6931 +
6932 + foreach ($counts as $status => $data) {
6933 + $count = absint($data->count);
6934 + $total += $count;
6935 +
6936 + switch ($status) {
6937 + case 'completed':
6938 + $completed = $count;
6939 + break;
6940 + case 'failed':
6941 + $failed = $count;
6942 + break;
6943 + case 'processing':
6944 + $processing = $count;
6945 + break;
6946 + case 'pending':
6947 + $pending = $count;
6948 + break;
6949 + }
6950 + }
6951 +
6952 + // Calculate percentage
6953 + $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
6954 +
6955 + // Get failed items details
6956 + $failed_items = array();
6957 + if ($failed > 0) {
6958 + $failed_items = $wpdb->get_results($wpdb->prepare(
6959 + "SELECT item_type, item_data, error_message, attempts
6960 + FROM $table_name
6961 + WHERE queue_id = %s
6962 + AND status = 'failed'
6963 + AND attempts >= max_attempts
6964 + ORDER BY id DESC
6965 + LIMIT 50",
6966 + $queue_id
6967 + ));
6968 + }
6969 +
6970 + // Get queue metadata
6971 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
6972 + $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
6973 +
6974 + // Determine if queue is complete
6975 + $is_complete = ($pending === 0 && $processing === 0);
6976 +
6977 + wp_send_json_success(array(
6978 + 'queue_id' => $queue_id,
6979 + 'queue_type' => $queue_type,
6980 + 'source_url' => $source_url,
6981 + 'total' => $total,
6982 + 'completed' => $completed,
6983 + 'failed' => $failed,
6984 + 'processing' => $processing,
6985 + 'pending' => $pending,
6986 + 'percentage' => $percentage,
6987 + 'is_complete' => $is_complete,
6988 + 'failed_items' => $failed_items,
6989 + 'status' => $is_complete ? 'complete' : 'processing'
6990 + ));
6991 +}
6992 +
6993 +/**
6994 + * AJAX: Clear completed queue
6995 + */
6996 +public function ajax_mxchat_clear_queue() {
6997 + // Verify nonce and permissions
6998 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6999 +
7000 + if (!current_user_can('manage_options')) {
7001 + wp_send_json_error('Unauthorized access');
7002 + }
7003 +
7004 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7005 +
7006 + if (empty($queue_id)) {
7007 + wp_send_json_error('Missing queue ID');
7008 + }
7009 +
7010 + global $wpdb;
7011 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7012 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7013 +
7014 + // Delete queue items
7015 + $wpdb->delete(
7016 + $table_name,
7017 + array('queue_id' => $queue_id),
7018 + array('%s')
7019 + );
7020 +
7021 + // Delete queue metadata
7022 + $wpdb->delete(
7023 + $meta_table,
7024 + array('queue_id' => $queue_id),
7025 + array('%s')
7026 + );
7027 +
7028 + wp_send_json_success(array(
7029 + 'message' => 'Queue cleared successfully'
7030 + ));
7031 +}
7032 +
7033 +/**
7034 + * AJAX: Retry failed items in queue
7035 + */
7036 +public function ajax_mxchat_retry_failed() {
7037 + // Verify nonce and permissions
7038 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
7039 +
7040 + if (!current_user_can('manage_options')) {
7041 + wp_send_json_error('Unauthorized access');
7042 + }
7043 +
7044 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7045 +
7046 + if (empty($queue_id)) {
7047 + wp_send_json_error('Missing queue ID');
7048 + }
7049 +
7050 + global $wpdb;
7051 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7052 +
7053 + // Reset failed items to pending and reset attempt count
7054 + $updated = $wpdb->update(
7055 + $table_name,
7056 + array(
7057 + 'status' => 'pending',
7058 + 'attempts' => 0,
7059 + 'error_message' => null
7060 + ),
7061 + array(
7062 + 'queue_id' => $queue_id,
7063 + 'status' => 'failed'
7064 + ),
7065 + array('%s', '%d', '%s'),
7066 + array('%s', '%s')
7067 + );
7068 +
7069 + wp_send_json_success(array(
7070 + 'message' => 'Reset ' . $updated . ' failed items for retry',
7071 + 'reset_count' => $updated
7072 + ));
7073 +}
7074 +
7075 +
7076 +public function ajax_mxchat_mark_queue_complete() {
7077 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
7078 +
7079 + if (!current_user_can('manage_options')) {
7080 + wp_send_json_error('Unauthorized access');
7081 + }
7082 +
7083 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7084 +
7085 + if (empty($queue_id)) {
7086 + wp_send_json_error('Missing queue ID');
7087 + }
7088 +
7089 + // Clear active queue transients
7090 + if (strpos($queue_id, 'sitemap_') === 0) {
7091 + delete_transient('mxchat_active_queue_sitemap');
7092 + } else if (strpos($queue_id, 'pdf_') === 0) {
7093 + delete_transient('mxchat_active_queue_pdf');
7094 + }
7095 +
7096 + wp_send_json_success(array('message' => 'Queue marked as complete'));
7097 +}
7098 +
7099 +
7100 + // ========================================
7101 + // STATIC ACCESS METHODS
7102 + // ========================================
7103 +
7104 + /**
7105 + * Get singleton instance
7106 + */
7107 + public static function get_instance() {
7108 + static $instance = null;
7109 + if ($instance === null) {
7110 + $instance = new self();
7111 + }
7112 + return $instance;
7113 + }
7114 +}
7115 +
7116 +// Initialize the Knowledge manager
8656 7117 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();