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

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

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