PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.22
MxChat – AI Chatbot & Content Generation for WordPress v3.2.22
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / admin / class-knowledge-manager.php

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

10,339 lines 423.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 // Media-library attachments (plan 01010a). Attachments never reach the post
94 // hooks above in a usable state — post_updated fires but post_status is
95 // 'inherit', not 'publish', so the indexing branch always returns early — and
96 // wp_delete_attachment() fires ONLY 'delete_attachment' (wp_delete_post()
97 // delegates to it before its own before_delete_post), so the post-delete
98 // handler never sees one. Both paths therefore need their own hook, and both
99 // are gated on the SAME 'mxchat_auto_sync_attachment' toggle the Advanced
100 // Custom Post Sync Settings list already renders for Media.
101 add_action('add_attachment', array($this, 'mxchat_handle_attachment_change'));
102 add_action('edit_attachment', array($this, 'mxchat_handle_attachment_change'));
103 add_action('delete_attachment', array($this, 'mxchat_handle_attachment_delete'));
104
105 // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
106 // Priority 20 to run after ACF's own save (which runs at priority 10)
107 add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
108
109 // One-time cleanup for vectors orphaned by unpublishes that predate the
110 // transition_post_status handler (plan 816fb1): wp mxchat prune-unpublished
111 if (defined('WP_CLI') && WP_CLI) {
112 WP_CLI::add_command('mxchat prune-unpublished', array($this, 'cli_prune_unpublished'));
113 // In-place repair for RTL KB rows imported in visual order before the
114 // 32bf9e normalizer existed: wp mxchat rtl-repair (plan d1e6f7)
115 WP_CLI::add_command('mxchat rtl-repair', array($this, 'cli_rtl_repair'));
116 }
117
118 add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
119
120 // WooCommerce product hooks (if WooCommerce is active)
121 if (class_exists('WooCommerce')) {
122 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
123 add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
124 add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
125 add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
126 }
127 }
128
129 /**
130 * Get current options (refreshed)
131 */
132 private function mxchat_get_options() {
133 if (empty($this->options)) {
134 $this->options = get_option('mxchat_options', array());
135 }
136 return $this->options;
137 }
138
139
140 // ========================================
141 // MAIN CONTENT SUBMISSION HANDLERS
142 // ========================================
143
144 public function mxchat_handle_content_submission() {
145 // Check if the form was submitted and the user has permission.
146 if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
147 return;
148 }
149
150 // Verify the nonce.
151 $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
152 if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
153 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
154 }
155
156 // Sanitize the inputs.
157 // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
158 $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
159 $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
160
161 // Get bot_id from form submission
162 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
163
164 // Get bot-specific options and API key
165 $bot_options = $this->get_bot_options($bot_id);
166 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
167
168 // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd).
169 $preflight = MxChat_Utils::embedding_preflight($options);
170 if (!$preflight['ok']) {
171 set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
172 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
173 exit;
174 }
175 $api_key = $preflight['api_key'];
176
177 // Use centralized utility function with bot_id
178 $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
179
180 if (is_wp_error($result)) {
181 set_transient('mxchat_admin_notice_error',
182 esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
183 30
184 );
185 } else {
186 set_transient('mxchat_admin_notice_success',
187 esc_html__('Content successfully submitted!', 'mxchat'),
188 30
189 );
190 }
191
192 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
193 exit;
194 }
195
196 /**
197 * Handle the "YouTube" KB import source (admin-post form submission).
198 *
199 * Per-video description mode:
200 * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
201 * If no usable transcript, index the metadata anyway, tell the admin,
202 * and bounce back with the manual box pre-filled (never fail silently).
203 * - manual: the admin's own description is what gets indexed; metadata rides along.
204 *
205 * The row is stored with content_type 'youtube' and source_url = the canonical
206 * watch URL, so re-importing the same video UPDATES the entry (source_url
207 * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
208 * "augment a metadata-only entry" path.
209 */
210 public function mxchat_handle_youtube_submission() {
211 if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
212 wp_die(esc_html__('Unauthorized access', 'mxchat'));
213 }
214
215 check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
216
217 $redirect_url = admin_url('admin.php?page=mxchat-prompts');
218
219 $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
220 $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
221
222 if (empty($video_id)) {
223 set_transient('mxchat_admin_notice_error',
224 esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
225 30
226 );
227 wp_safe_redirect(esc_url($redirect_url));
228 exit;
229 }
230
231 $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
232
233 $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
234 $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
235
236 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
237
238 // Resolve the embedding decision exactly like the sibling handlers —
239 // custom-provider-aware (plan cbd5fd).
240 $bot_options = $this->get_bot_options($bot_id);
241 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
242
243 $preflight = MxChat_Utils::embedding_preflight($options);
244 if (!$preflight['ok']) {
245 set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
246 wp_safe_redirect(esc_url($redirect_url));
247 exit;
248 }
249 $api_key = $preflight['api_key'];
250
251 // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
252 // manual mode it enriches the indexed text with the real title/channel.
253 $meta = $this->mxchat_fetch_youtube_oembed($video_id);
254 $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
255 $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
256
257 $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
258 if ($video_channel !== '') {
259 $header_lines .= 'Channel: ' . $video_channel . "\n";
260 }
261 $header_lines .= 'URL: ' . $canonical_url . "\n\n";
262
263 $transcript_missing = false;
264
265 if ($description_mode === 'manual') {
266 if ($manual_description === '') {
267 set_transient('mxchat_admin_notice_error',
268 esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
269 30
270 );
271 wp_safe_redirect(esc_url($redirect_url));
272 exit;
273 }
274 $indexed_text = $header_lines . $manual_description;
275 } else {
276 $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
277
278 if (strlen($transcript) >= 200) {
279 $indexed_text = $header_lines . $transcript;
280 } else {
281 // Graceful fallback: captions disabled / blocked / no speech. Auto
282 // reliably gets metadata; it does NOT guarantee a transcript.
283 $transcript_missing = true;
284
285 if ($video_title === '' && $video_channel === '') {
286 // Both halves failed — nothing meaningful to index.
287 set_transient('mxchat_admin_notice_error',
288 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'),
289 30
290 );
291 wp_safe_redirect(esc_url($redirect_url));
292 exit;
293 }
294
295 $indexed_text = $header_lines . sprintf(
296 /* translators: 1: video title, 2: channel name */
297 __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
298 $video_title !== '' ? $video_title : $canonical_url,
299 $video_channel !== '' ? $video_channel : 'YouTube'
300 );
301 }
302 }
303
304 $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
305
306 if (is_wp_error($result)) {
307 set_transient('mxchat_admin_notice_error',
308 esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
309 30
310 );
311 wp_safe_redirect(esc_url($redirect_url));
312 exit;
313 }
314
315 if ($transcript_missing) {
316 set_transient('mxchat_admin_notice_success',
317 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'),
318 30
319 );
320 // Bounce back with prefill args so the page reopens the YouTube form in
321 // manual mode with the URL + fetched title ready to augment.
322 $redirect_url = add_query_arg(array(
323 'mxchat_yt_prefill' => '1',
324 'yt_url' => rawurlencode($canonical_url),
325 'yt_title' => rawurlencode($video_title),
326 ), $redirect_url);
327 } else {
328 set_transient('mxchat_admin_notice_success',
329 esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
330 30
331 );
332 }
333
334 wp_safe_redirect(esc_url_raw($redirect_url));
335 exit;
336 }
337
338 /**
339 * Fetch YouTube oEmbed metadata for a video (no API key required).
340 * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
341 */
342 private function mxchat_fetch_youtube_oembed($video_id) {
343 $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
344 $response = wp_remote_get($oembed_url, array('timeout' => 15));
345 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
346 return array();
347 }
348 $data = json_decode(wp_remote_retrieve_body($response), true);
349 return is_array($data) ? $data : array();
350 }
351
352 /**
353 * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
354 * YouTube's unofficial timedtext route (the caption track list embedded in the
355 * watch page), which YouTube has broken before and will break again. Every
356 * failure mode returns '' so a break degrades to the metadata-only import path
357 * instead of erroring the whole submission. Do not let anything in here throw.
358 */
359 private function mxchat_fetch_youtube_transcript($video_id) {
360 $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
361
362 // First try the honest ingest UA; some responses omit the player config for
363 // bot UAs, so retry once with a browser UA before giving up.
364 $user_agents = array(
365 mxchat_ingest_user_agent(),
366 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
367 );
368
369 $tracks = array();
370 foreach ($user_agents as $ua) {
371 $response = wp_remote_get($watch_url, array(
372 'timeout' => 20,
373 'user-agent' => $ua,
374 ));
375 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
376 continue;
377 }
378 $body = wp_remote_retrieve_body($response);
379 if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
380 continue;
381 }
382 $decoded = json_decode($m[1], true);
383 if (is_array($decoded) && !empty($decoded)) {
384 $tracks = $decoded;
385 break;
386 }
387 }
388
389 if (empty($tracks)) {
390 return '';
391 }
392
393 // Prefer an English track, else take the first offered.
394 $chosen = null;
395 foreach ($tracks as $track) {
396 if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
397 $chosen = $track;
398 break;
399 }
400 }
401 if ($chosen === null) {
402 $chosen = $tracks[0];
403 }
404 if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
405 return '';
406 }
407
408 $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
409 if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
410 return '';
411 }
412 $xml = wp_remote_retrieve_body($timedtext);
413 if (!is_string($xml) || strpos($xml, '<text') === false) {
414 return '';
415 }
416
417 // <text start=".." dur="..">caption</text> — strip tags, decode the
418 // double-encoded entities timedtext ships, collapse whitespace.
419 $text = preg_replace('/<[^>]+>/', ' ', $xml);
420 $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
421 $text = trim(preg_replace('/\s+/u', ' ', $text));
422
423 return $text;
424 }
425
426 public function mxchat_is_pdf_url($url, $response) {
427 $content_type = wp_remote_retrieve_header($response, 'content-type');
428 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
429
430 // Check Content-Disposition header for .pdf filename (Google Drive sends this)
431 $disposition = wp_remote_retrieve_header($response, 'content-disposition');
432 $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
433
434 return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
435 }
436
437
438 public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
439 if (!current_user_can('manage_options')) {
440 return false;
441 }
442
443 $pdf_url = esc_url_raw($pdf_url);
444 $upload_dir = wp_upload_dir();
445
446 if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
447 return false;
448 }
449
450 $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
451 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
452
453 $response_body = wp_remote_retrieve_body($response);
454 if (empty($response_body)) {
455 return false;
456 }
457
458 if (!wp_mkdir_p(dirname($pdf_path))) {
459 return false;
460 }
461
462 try {
463 file_put_contents($pdf_path, $response_body);
464
465 if (!file_exists($pdf_path)) {
466 throw new Exception(__('Failed to save PDF file', 'mxchat'));
467 }
468
469 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
470
471 if ($total_pages === false || $total_pages < 1) {
472 throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
473 }
474
475 // Create unique queue ID
476 $queue_id = 'pdf_' . md5($pdf_url . time());
477
478 // Create array of pages to process
479 $pages = array();
480 for ($i = 1; $i <= $total_pages; $i++) {
481 $pages[] = array(
482 'pdf_path' => $pdf_path,
483 'pdf_url' => $pdf_url,
484 'page_number' => $i,
485 'total_pages' => $total_pages
486 );
487 }
488
489 // Add pages to queue
490 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
491
492 if ($queued_count === 0) {
493 wp_delete_file($pdf_path);
494 throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
495 }
496
497 // Store queue metadata
498 $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
499 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
500 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
501 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
502 $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
503 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
504
505 // Store queue ID in transient for status tracking
506 set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
507 set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
508
509 return 'queued';
510
511 } catch (Exception $e) {
512 if (file_exists($pdf_path)) {
513 wp_delete_file($pdf_path);
514 }
515 return $e->getMessage();
516 }
517 }
518
519 /**
520 * Handle direct PDF file upload from the knowledge base page
521 */
522 public function mxchat_handle_pdf_file_submission() {
523 if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
524 wp_die(esc_html__('Unauthorized access', 'mxchat'));
525 }
526
527 check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
528
529 $redirect_url = admin_url('admin.php?page=mxchat-prompts');
530
531 // Validate file upload
532 if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
533 $error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
534 $error_messages = array(
535 UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
536 UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
537 UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
538 UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
539 UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
540 UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
541 );
542 $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
543 set_transient('mxchat_admin_notice_error', $error_msg, 30);
544 wp_safe_redirect(esc_url($redirect_url));
545 exit;
546 }
547
548 $file = $_FILES['pdf_file'];
549
550 // Validate MIME type
551 $finfo = finfo_open(FILEINFO_MIME_TYPE);
552 $mime_type = finfo_file($finfo, $file['tmp_name']);
553 finfo_close($finfo);
554
555 if ($mime_type !== 'application/pdf') {
556 set_transient('mxchat_admin_notice_error',
557 esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
558 30
559 );
560 wp_safe_redirect(esc_url($redirect_url));
561 exit;
562 }
563
564 // Validate extension
565 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
566 if ($ext !== 'pdf') {
567 set_transient('mxchat_admin_notice_error',
568 esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
569 30
570 );
571 wp_safe_redirect(esc_url($redirect_url));
572 exit;
573 }
574
575 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
576 $original_filename = sanitize_file_name($file['name']);
577
578 $upload_dir = wp_upload_dir();
579 if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
580 set_transient('mxchat_admin_notice_error',
581 esc_html__('WordPress upload directory is not writable.', 'mxchat'),
582 30
583 );
584 wp_safe_redirect(esc_url($redirect_url));
585 exit;
586 }
587
588 $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
589 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
590
591 if (!wp_mkdir_p(dirname($pdf_path))) {
592 set_transient('mxchat_admin_notice_error',
593 esc_html__('Failed to create upload directory.', 'mxchat'),
594 30
595 );
596 wp_safe_redirect(esc_url($redirect_url));
597 exit;
598 }
599
600 // Move uploaded file
601 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
602 set_transient('mxchat_admin_notice_error',
603 esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
604 30
605 );
606 wp_safe_redirect(esc_url($redirect_url));
607 exit;
608 }
609
610 try {
611 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
612
613 if ($total_pages === false || $total_pages < 1) {
614 throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
615 }
616
617 // Use original filename as the source identifier
618 $source_label = 'upload://' . $original_filename;
619
620 $queue_id = 'pdf_' . md5($source_label . time());
621
622 $pages = array();
623 for ($i = 1; $i <= $total_pages; $i++) {
624 $pages[] = array(
625 'pdf_path' => $pdf_path,
626 'pdf_url' => $source_label,
627 'page_number' => $i,
628 'total_pages' => $total_pages,
629 );
630 }
631
632 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
633
634 if ($queued_count === 0) {
635 wp_delete_file($pdf_path);
636 throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
637 }
638
639 $this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
640 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
641 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
642 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
643 $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
644 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
645
646 set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
647 set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
648
649 set_transient('mxchat_admin_notice_success',
650 sprintf(
651 esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
652 esc_html($original_filename),
653 $total_pages
654 ),
655 30
656 );
657
658 } catch (Exception $e) {
659 if (file_exists($pdf_path)) {
660 wp_delete_file($pdf_path);
661 }
662 set_transient('mxchat_admin_notice_error',
663 esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
664 30
665 );
666 }
667
668 wp_safe_redirect(esc_url($redirect_url));
669 exit;
670 }
671
672 /**
673 * Handle direct document upload (.docx / .txt / .md) from the knowledge base
674 * page (plan 0485e5). Unlike PDF Upload there is no per-page queue: the text
675 * extracts in one pass and routes through submit_content_to_db, whose chunker
676 * takes over for long content. The uploaded file is read from the PHP temp
677 * file and never persisted — only its extracted text enters the KB.
678 *
679 * Source identity matches PDF Upload's scheme: upload://<filename>, stable
680 * across re-uploads so a re-import REPLACES (delete_chunks_for_url + upsert
681 * per identity) instead of duplicating.
682 */
683 public function mxchat_handle_document_file_submission() {
684 if (!isset($_POST['submit_document_file']) || !current_user_can('manage_options')) {
685 wp_die(esc_html__('Unauthorized access', 'mxchat'));
686 }
687
688 check_admin_referer('mxchat_submit_document_file_action', 'mxchat_submit_document_file_nonce');
689
690 $redirect_url = admin_url('admin.php?page=mxchat-prompts');
691
692 if (empty($_FILES['document_file']) || $_FILES['document_file']['error'] !== UPLOAD_ERR_OK) {
693 $error_code = isset($_FILES['document_file']['error']) ? $_FILES['document_file']['error'] : UPLOAD_ERR_NO_FILE;
694 $error_messages = array(
695 UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
696 UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
697 UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
698 UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a document.', 'mxchat'),
699 UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
700 UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
701 );
702 $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
703 set_transient('mxchat_admin_notice_error', $error_msg, 30);
704 wp_safe_redirect(esc_url($redirect_url));
705 exit;
706 }
707
708 $file = $_FILES['document_file'];
709 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
710
711 $finfo = finfo_open(FILEINFO_MIME_TYPE);
712 $mime_type = finfo_file($finfo, $file['tmp_name']);
713 finfo_close($finfo);
714
715 // Per-extension MIME expectations. finfo commonly reports .docx as
716 // application/zip (it IS a Zip container) and .md as plain text.
717 $mime_ok = false;
718 if ($ext === 'docx') {
719 $mime_ok = in_array($mime_type, array(
720 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
721 'application/zip',
722 ), true);
723 } elseif ($ext === 'txt' || $ext === 'md') {
724 $mime_ok = (strpos((string) $mime_type, 'text/') === 0);
725 }
726
727 if (!$mime_ok) {
728 set_transient('mxchat_admin_notice_error',
729 esc_html__('Invalid or unreadable document. Accepted types: .docx, .txt, .md.', 'mxchat'),
730 30
731 );
732 wp_safe_redirect(esc_url($redirect_url));
733 exit;
734 }
735
736 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
737 $original_filename = sanitize_file_name($file['name']);
738
739 // ---- Extract text (ONE extractor for .docx — the word handler's) ----
740 if ($ext === 'docx') {
741 $text = MXChat_Word_Handler::extract_docx_text($file['tmp_name']);
742 if ($text === false) {
743 set_transient('mxchat_admin_notice_error',
744 esc_html__('The .docx file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
745 30
746 );
747 wp_safe_redirect(esc_url($redirect_url));
748 exit;
749 }
750 } else {
751 // .txt / .md read as-is. Markdown keeps its syntax on purpose —
752 // headings are useful retrieval signal.
753 $text = (string) file_get_contents($file['tmp_name']);
754 $text = wp_check_invalid_utf8($text);
755 $text = trim($text);
756 }
757
758 if ($text === '') {
759 set_transient('mxchat_admin_notice_error',
760 esc_html__('The uploaded document contains no readable text.', 'mxchat'),
761 30
762 );
763 wp_safe_redirect(esc_url($redirect_url));
764 exit;
765 }
766
767 // Size cap — same pdf_max_pages setting the PDF/toolbar paths use, but
768 // estimated by CHARACTERS (~2500/page): the .docx cleaner collapses all
769 // newlines to spaces, so a paragraph count reads 1 for any Word file.
770 // Processing is synchronous — an unbounded document risks a timeout.
771 $options = get_option('mxchat_options', array());
772 $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
773 $estimated_pages = (int) ceil(strlen($text) / 2500);
774 if ($estimated_pages > $max_pages) {
775 set_transient('mxchat_admin_notice_error',
776 sprintf(
777 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'),
778 $estimated_pages,
779 $max_pages
780 ),
781 30
782 );
783 wp_safe_redirect(esc_url($redirect_url));
784 exit;
785 }
786
787 // Embedding API key — bot-aware, same shape as the direct-content handler.
788 $api_key = '';
789 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
790 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
791 $api_key = $bot_options['api_key'] ?? '';
792 }
793 if (empty($api_key)) {
794 $api_key = $options['api_key'] ?? '';
795 }
796
797 // Stable identity — PDF Upload's scheme. A re-upload of the same filename
798 // replaces: clear old chunks first (covers a doc shrinking below the chunk
799 // threshold, where the single-vector path would not clean them), then
800 // submit — the chunked path re-deletes harmlessly.
801 $source_label = 'upload://' . $original_filename;
802 MxChat_Utils::delete_chunks_for_url($source_label, $bot_id);
803 $result = MxChat_Utils::submit_content_to_db($text, $source_label, $api_key, null, $bot_id, 'document');
804
805 if (is_wp_error($result)) {
806 set_transient('mxchat_admin_notice_error',
807 esc_html__('Failed to import the document: ', 'mxchat') . esc_html($result->get_error_message()),
808 30
809 );
810 } else {
811 set_transient('mxchat_admin_notice_success',
812 sprintf(
813 esc_html__('Document "%s" imported into the knowledge base.', 'mxchat'),
814 esc_html($original_filename)
815 ),
816 30
817 );
818 }
819
820 wp_safe_redirect(esc_url($redirect_url));
821 exit;
822 }
823
824 /**
825 * Validate PDF and count pages with multiple parser attempts
826 */
827 private function mxchat_validate_and_count_pdf_pages($pdf_path) {
828 // Method 1: Try with Smalot PDF Parser (your current method)
829 try {
830 mxchat_load_pdf_parser();
831 $parser = new \Smalot\PdfParser\Parser();
832 $pdf = $parser->parseFile($pdf_path);
833 $pages = $pdf->getPages();
834 $page_count = count($pages);
835
836 if ($page_count > 0) {
837 //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
838 return $page_count;
839 }
840 } catch (Exception $e) {
841 //error_log('Smalot PDF parser failed: ' . $e->getMessage());
842 }
843
844 // Method 2: Try with pdfinfo command (if available)
845 if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
846 try {
847 $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
848 $output = shell_exec($command);
849
850 if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
851 $page_count = intval($matches[1]);
852 if ($page_count > 0) {
853 //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
854 return $page_count;
855 }
856 }
857 } catch (Exception $e) {
858 //error_log('pdfinfo command failed: ' . $e->getMessage());
859 }
860 }
861
862 // Method 3: Try to repair PDF and parse again
863 try {
864 $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
865 if ($repaired_path && $repaired_path !== $pdf_path) {
866 mxchat_load_pdf_parser();
867 $parser = new \Smalot\PdfParser\Parser();
868 $pdf = $parser->parseFile($repaired_path);
869 $pages = $pdf->getPages();
870 $page_count = count($pages);
871
872 if ($page_count > 0) {
873 // Replace original with repaired version
874 copy($repaired_path, $pdf_path);
875 unlink($repaired_path);
876 //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
877 return $page_count;
878 }
879
880 // Clean up repaired file if it didn't work
881 unlink($repaired_path);
882 }
883 } catch (Exception $e) {
884 //error_log('PDF repair attempt failed: ' . $e->getMessage());
885 }
886
887 // Method 4: Manual PDF structure analysis (basic page count)
888 try {
889 $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
890 if ($page_count > 0) {
891 //error_log('PDF page count determined manually: ' . $page_count . ' pages');
892 return $page_count;
893 }
894 } catch (Exception $e) {
895 //error_log('Manual PDF analysis failed: ' . $e->getMessage());
896 }
897
898 //error_log('All PDF parsing methods failed for: ' . $pdf_path);
899 return false;
900 }
901
902 /**
903 * Check if shell_exec is disabled
904 */
905 private function mxchat_is_shell_disabled() {
906 $disabled = explode(',', ini_get('disable_functions'));
907 return in_array('shell_exec', $disabled);
908 }
909
910 /**
911 * Attempt to repair PDF using basic methods
912 */
913 private function mxchat_attempt_pdf_repair($pdf_path) {
914 try {
915 $content = file_get_contents($pdf_path);
916 if (!$content) {
917 return false;
918 }
919
920 // Check if PDF starts with proper header
921 if (substr($content, 0, 4) !== '%PDF') {
922 // Try to find PDF header in the content
923 $header_pos = strpos($content, '%PDF');
924 if ($header_pos !== false && $header_pos < 1024) {
925 // Remove junk before PDF header
926 $content = substr($content, $header_pos);
927 $repaired_path = $pdf_path . '.repaired';
928 file_put_contents($repaired_path, $content);
929 return $repaired_path;
930 }
931 }
932
933 // Check for EOF marker
934 $content = rtrim($content);
935 if (!preg_match('/%%EOF\s*$/', $content)) {
936 // Add EOF marker if missing
937 $content .= "\n%%EOF";
938 $repaired_path = $pdf_path . '.repaired';
939 file_put_contents($repaired_path, $content);
940 return $repaired_path;
941 }
942
943 } catch (Exception $e) {
944 //error_log('PDF repair error: ' . $e->getMessage());
945 }
946
947 return false;
948 }
949
950 /**
951 * Manual PDF page counting by analyzing PDF structure
952 */
953 private function mxchat_manual_pdf_page_count($pdf_path) {
954 try {
955 $content = file_get_contents($pdf_path);
956 if (!$content) {
957 return 0;
958 }
959
960 // Method 1: Count /Type /Page objects
961 $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
962 if ($page_count > 0) {
963 return $page_count;
964 }
965
966 // Method 2: Look for /Count in pages object
967 if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
968 return intval($matches[1]);
969 }
970
971 // Method 3: Count page references
972 $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
973 if ($page_count > 0) {
974 return $page_count;
975 }
976
977 } catch (Exception $e) {
978 //error_log('Manual PDF analysis error: ' . $e->getMessage());
979 }
980
981 return 0;
982 }
983
984
985 public function mxchat_save_inline_prompt() {
986 // DEBUG: Log what we're receiving
987 //error_log('=== MXCHAT DEBUG ===');
988 //error_log('POST data: ' . print_r($_POST, true));
989 //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
990
991 // Check for nonce security
992 check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
993
994 // If we get here, nonce passed
995 //error_log('Nonce verification PASSED');
996
997 // Verify permissions
998 if (!current_user_can('manage_options')) {
999 wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
1000 return;
1001 }
1002
1003 global $wpdb;
1004 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1005
1006 // Validate and sanitize input data
1007 $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
1008 $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
1009 $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
1010
1011 if ($prompt_id > 0 && !empty($article_content)) {
1012 // Re-generate the embedding vector for the updated content
1013 $embedding_vector = $this->mxchat_generate_embedding($article_content);
1014 if (is_array($embedding_vector)) {
1015 // Serialize the embedding vector before storing it
1016 $embedding_vector_serialized = serialize($embedding_vector);
1017 // Update the prompt in the database
1018 $updated = $wpdb->update(
1019 $table_name,
1020 array(
1021 'article_content' => $article_content,
1022 'embedding_vector' => $embedding_vector_serialized,
1023 'source_url' => $article_url,
1024 ),
1025 array('id' => $prompt_id),
1026 array('%s', '%s', '%s'),
1027 array('%d')
1028 );
1029 if ($updated !== false) {
1030 wp_send_json_success();
1031 } else {
1032 MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
1033 wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
1034 }
1035 } else {
1036 MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
1037 wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
1038 }
1039 } else {
1040 wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
1041 }
1042 }
1043
1044
1045 /**
1046 * AJAX: Get full content for editing — reassembles chunks if needed.
1047 * Works for both WordPress DB and Pinecone entries.
1048 */
1049 /**
1050 * Sanitize a knowledge entry's source_url from an AJAX request WITHOUT destroying
1051 * its identity. sanitize_text_field() strips percent-encoded octets (%20, %D7%A9…),
1052 * so a percent-encoded URL — every non-ASCII permalink — would md5 to a DIFFERENT
1053 * id than the one it was stored under: reads miss the entry and saves write an
1054 * orphan copy while the original keeps its stale text. URLs get esc_url_raw
1055 * (identity-preserving, matches what import stored); non-URL keys (mxchat://,
1056 * _ungrouped_) keep the old sanitizer.
1057 */
1058 private function sanitize_entry_source_url( $raw ) {
1059 $raw = trim( (string) $raw );
1060 if ( preg_match( '#^https?://#i', $raw ) ) {
1061 return esc_url_raw( $raw );
1062 }
1063 return sanitize_text_field( $raw );
1064 }
1065
1066 public function ajax_mxchat_get_entry_content() {
1067 check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1068
1069 if ( ! current_user_can('manage_options') ) {
1070 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1071 }
1072
1073 $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1074 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1075 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1076 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1077
1078 if ( $data_source === 'pinecone' ) {
1079 // Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
1080 // destroy them, so re-read the raw value for this branch only.
1081 $vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1082 // Pinecone: fetch vectors by source_url, reassemble chunks
1083 $content = $this->get_pinecone_entry_content( $source_url, $vector_id, $bot_id );
1084 } else {
1085 // WordPress DB
1086 $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
1087 }
1088
1089 if ( is_wp_error( $content ) ) {
1090 wp_send_json_error( array( 'message' => $content->get_error_message() ) );
1091 }
1092
1093 wp_send_json_success( $content );
1094 }
1095
1096 /**
1097 * Get content from WordPress DB — reassembles chunks by source_url.
1098 */
1099 private function get_wordpress_entry_content( $source_url, $entry_id ) {
1100 global $wpdb;
1101 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1102
1103 // If we have a source_url, check for chunks
1104 if ( ! empty( $source_url ) && strpos( $source_url, 'mxchat://' ) !== 0 ) {
1105 $rows = $wpdb->get_results( $wpdb->prepare(
1106 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1107 $source_url
1108 ) );
1109
1110 if ( $rows && count( $rows ) > 1 ) {
1111 // Multiple rows = chunked. Reassemble.
1112 $chunks = array();
1113 foreach ( $rows as $row ) {
1114 $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1115 $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1116 $chunks[ $index ] = $parsed['text'];
1117 }
1118 ksort( $chunks );
1119 return array(
1120 'content' => implode( "\n\n", $chunks ),
1121 'source_url' => $source_url,
1122 'is_chunked' => true,
1123 'chunk_count' => count( $chunks ),
1124 'content_type' => $rows[0]->content_type,
1125 );
1126 } elseif ( $rows && count( $rows ) === 1 ) {
1127 $parsed = MxChat_Chunker::parse_stored_chunk( $rows[0]->article_content );
1128 return array(
1129 'content' => $parsed['text'],
1130 'source_url' => $source_url,
1131 'entry_id' => $rows[0]->id,
1132 'is_chunked' => false,
1133 'content_type' => $rows[0]->content_type,
1134 );
1135 }
1136 }
1137
1138 // Fallback: fetch by ID
1139 if ( $entry_id > 0 ) {
1140 $row = $wpdb->get_row( $wpdb->prepare(
1141 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1142 $entry_id
1143 ) );
1144 if ( $row ) {
1145 $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1146 return array(
1147 'content' => $parsed['text'],
1148 'source_url' => $row->source_url,
1149 'entry_id' => $row->id,
1150 'is_chunked' => false,
1151 'content_type' => $row->content_type,
1152 );
1153 }
1154 }
1155
1156 return new WP_Error( 'not_found', 'Entry not found.' );
1157 }
1158
1159 /**
1160 * Get content from Pinecone — fetches vectors by source_url, reassembles chunks.
1161 */
1162 private function get_pinecone_entry_content( $source_url, $entry_id, $bot_id ) {
1163 if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1164 return new WP_Error( 'pinecone_unavailable', 'Pinecone manager not available.' );
1165 }
1166
1167 // Get Pinecone config
1168 if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1169 $pinecone_options = get_option('mxchat_pinecone_addon_options');
1170 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1171 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1172 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1173 } else {
1174 $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1175 $api_key = $bot_config['api_key'] ?? '';
1176 $host = $bot_config['host'] ?? '';
1177 $namespace = $bot_config['namespace'] ?? '';
1178 }
1179
1180 if ( empty($host) || empty($api_key) ) {
1181 return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
1182 }
1183
1184 // Document index (plan 362c31): same ids, read through the documents API.
1185 if ( class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index( $bot_id ) ) {
1186 $docs_cfg = array( 'api_key' => $api_key, 'host' => $host, 'namespace' => $namespace );
1187 if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1188 $doc_ids = array( $entry_id );
1189 } else {
1190 $doc_ids = array_merge( array( md5( $source_url ) ), MxChat_Pinecone_Documents::list_ids( md5( $source_url ) . '_chunk_', $docs_cfg ) );
1191 }
1192 $docs = MxChat_Pinecone_Documents::fetch( $doc_ids, $docs_cfg, array( 'text', 'chunk_index', 'type' ) );
1193 if ( empty($docs) ) {
1194 return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1195 }
1196 $doc_chunks = array();
1197 $doc_type = 'content';
1198 foreach ( $docs as $doc ) {
1199 $doc_meta = $doc['metadata'] ?? array();
1200 $doc_chunks[ intval( $doc_meta['chunk_index'] ?? 0 ) ] = $doc_meta['text'] ?? '';
1201 $doc_type = $doc_meta['type'] ?? 'content';
1202 }
1203 ksort( $doc_chunks );
1204 return array(
1205 'content' => implode( "\n\n", $doc_chunks ),
1206 'source_url' => $source_url,
1207 'is_chunked' => count($doc_chunks) > 1,
1208 'chunk_count' => count($doc_chunks),
1209 'content_type' => $doc_type,
1210 );
1211 }
1212
1213 // Manual entries carry no source_url (their vector id is a minted manual_* string,
1214 // not md5 of anything the row can hand us) — fetch the exact vector instead.
1215 // '_ungrouped_' is the table view's synthetic display key for such rows.
1216 if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1217 $vector_ids = array( $entry_id );
1218 } else {
1219 // List vectors with the source_url prefix
1220 $base_id = md5( $source_url );
1221 $vector_ids = array( $base_id );
1222
1223 // Find chunk vectors
1224 // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1225 // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1226 $list_url = "https://{$host}/vectors/list";
1227 $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1228 if ( ! empty($namespace) ) {
1229 $list_params['namespace'] = $namespace;
1230 }
1231
1232 $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1233 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1234 'timeout' => 15,
1235 ) );
1236
1237 if ( ! is_wp_error($list_resp) ) {
1238 $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1239 if ( ! empty($list_data['vectors']) ) {
1240 foreach ( $list_data['vectors'] as $v ) {
1241 $vector_ids[] = $v['id'];
1242 }
1243 }
1244 }
1245 }
1246
1247 // Fetch vectors with metadata
1248 // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1249 // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1250 // the query string explicitly.
1251 $fetch_query = array();
1252 foreach ( $vector_ids as $fetch_vid ) {
1253 $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1254 }
1255 if ( ! empty($namespace) ) {
1256 $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1257 }
1258
1259 $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1260 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1261 'timeout' => 15,
1262 ) );
1263
1264 if ( is_wp_error($fetch_resp) ) {
1265 return new WP_Error( 'pinecone_fetch', 'Failed to fetch from Pinecone.' );
1266 }
1267
1268 $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1269 $vectors = $fetch_data['vectors'] ?? array();
1270
1271 if ( empty($vectors) ) {
1272 return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1273 }
1274
1275 // Reassemble chunks
1276 $chunks = array();
1277 $content_type = 'content';
1278 foreach ( $vectors as $vid => $vector ) {
1279 $meta = $vector['metadata'] ?? array();
1280 $text = $meta['text'] ?? '';
1281 $index = $meta['chunk_index'] ?? 0;
1282 $content_type = $meta['type'] ?? 'content';
1283 $chunks[ intval($index) ] = $text;
1284 }
1285 ksort( $chunks );
1286
1287 return array(
1288 'content' => implode( "\n\n", $chunks ),
1289 'source_url' => $source_url,
1290 'is_chunked' => count($chunks) > 1,
1291 'chunk_count' => count($chunks),
1292 'content_type' => $content_type,
1293 );
1294 }
1295
1296 /**
1297 * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1298 * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1299 * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1300 */
1301 public function ajax_mxchat_inspect_entry() {
1302 check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1303
1304 if ( ! current_user_can('manage_options') ) {
1305 wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1306 }
1307
1308 $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1309 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1310 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1311 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1312
1313 if ( $data_source === 'pinecone' ) {
1314 $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
1315 } else {
1316 $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1317 }
1318
1319 if ( is_wp_error( $result ) ) {
1320 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1321 }
1322
1323 wp_send_json_success( $result );
1324 }
1325
1326 /**
1327 * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1328 * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1329 * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1330 */
1331 private function inspect_wordpress_entry( $source_url, $entry_id ) {
1332 global $wpdb;
1333 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1334
1335 $rows = array();
1336
1337 // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1338 // Direct Content entries (the spec's manual-entry case), which share one
1339 // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1340 // display key (invented by the table view for rows with no source_url) is
1341 // excluded; those fall through to the entry_id lookup below.
1342 if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1343 $rows = $wpdb->get_results( $wpdb->prepare(
1344 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1345 $source_url
1346 ) );
1347 }
1348
1349 // Fallback / manual "Direct Content" entries: fetch the single row by id.
1350 if ( empty( $rows ) && $entry_id > 0 ) {
1351 $row = $wpdb->get_row( $wpdb->prepare(
1352 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1353 $entry_id
1354 ) );
1355 if ( $row ) {
1356 $rows = array( $row );
1357 }
1358 }
1359
1360 if ( empty( $rows ) ) {
1361 return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1362 }
1363
1364 $chunks = array();
1365 $content_type = '';
1366 foreach ( $rows as $row ) {
1367 $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1368 $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1369 $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1370 $content_type = $row->content_type;
1371 $chunks[] = array(
1372 'index' => $index,
1373 'text' => $text,
1374 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1375 'row_id' => intval( $row->id ),
1376 );
1377 }
1378
1379 usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1380
1381 $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1382
1383 return array(
1384 'store' => 'wordpress',
1385 'source_url' => $source_url,
1386 'content_type' => $content_type,
1387 'is_chunked' => count( $chunks ) > 1,
1388 'chunk_count' => count( $chunks ),
1389 'assembled' => $assembled,
1390 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1391 'chunks' => array_values( $chunks ),
1392 // WP-DB storage carries no separate vector metadata; surface that fact
1393 // rather than letting the owner guess (the spec's taxonomy question).
1394 'metadata' => array(),
1395 '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'),
1396 );
1397 }
1398
1399 /**
1400 * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1401 * but keeps each vector's text + metadata instead of imploding, so the owner can
1402 * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1403 * are present per chunk. READ-ONLY.
1404 */
1405 private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1406 if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1407 return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1408 }
1409
1410 if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1411 $pinecone_options = get_option('mxchat_pinecone_addon_options');
1412 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1413 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1414 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1415 } else {
1416 $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1417 $api_key = $bot_config['api_key'] ?? '';
1418 $host = $bot_config['host'] ?? '';
1419 $namespace = $bot_config['namespace'] ?? '';
1420 }
1421
1422 if ( empty($host) || empty($api_key) ) {
1423 return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1424 }
1425
1426 // Document index (plan 362c31): the documents API hands back the same
1427 // id => {metadata} map the classic fetch does, so the inspection below is shared.
1428 if ( class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index( $bot_id ) ) {
1429 $docs_cfg = array( 'api_key' => $api_key, 'host' => $host, 'namespace' => $namespace );
1430 $vector_ids = array_merge( array( md5( $source_url ) ), MxChat_Pinecone_Documents::list_ids( md5( $source_url ) . '_chunk_', $docs_cfg ) );
1431 $vectors = MxChat_Pinecone_Documents::fetch( $vector_ids, $docs_cfg );
1432 if ( empty($vectors) ) {
1433 return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1434 }
1435 } else {
1436 $base_id = md5( $source_url );
1437 $vector_ids = array( $base_id );
1438
1439 // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1440 // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1441 $list_url = "https://{$host}/vectors/list";
1442 $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1443 if ( ! empty($namespace) ) {
1444 $list_params['namespace'] = $namespace;
1445 }
1446
1447 $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1448 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1449 'timeout' => 15,
1450 ) );
1451
1452 if ( ! is_wp_error($list_resp) ) {
1453 $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1454 if ( ! empty($list_data['vectors']) ) {
1455 foreach ( $list_data['vectors'] as $v ) {
1456 $vector_ids[] = $v['id'];
1457 }
1458 }
1459 }
1460
1461 // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1462 // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1463 // the query string explicitly.
1464 $fetch_query = array();
1465 foreach ( $vector_ids as $fetch_vid ) {
1466 $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1467 }
1468 if ( ! empty($namespace) ) {
1469 $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1470 }
1471
1472 $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1473 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1474 'timeout' => 15,
1475 ) );
1476
1477 if ( is_wp_error($fetch_resp) ) {
1478 return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1479 }
1480
1481 $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1482 $vectors = $fetch_data['vectors'] ?? array();
1483
1484 if ( empty($vectors) ) {
1485 return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1486 }
1487 } // end classic (vector index) read
1488
1489 // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1490 // what is (and is NOT) stored per vector.
1491 $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1492 $chunks = array();
1493 $content_type = '';
1494 foreach ( $vectors as $vid => $vector ) {
1495 $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1496 $text = $meta['text'] ?? '';
1497 $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1498 $content_type = $meta['type'] ?? $content_type;
1499
1500 $clean_meta = array();
1501 foreach ( $meta_fields as $field ) {
1502 if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1503 $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1504 }
1505 }
1506
1507 $chunks[] = array(
1508 'index' => $index,
1509 'text' => $text,
1510 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1511 'vector_id' => (string) $vid,
1512 'metadata' => $clean_meta,
1513 );
1514 }
1515
1516 usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1517
1518 $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1519
1520 return array(
1521 'store' => 'pinecone',
1522 'source_url' => $source_url,
1523 'content_type' => $content_type,
1524 'is_chunked' => count( $chunks ) > 1,
1525 'chunk_count' => count( $chunks ),
1526 'assembled' => $assembled,
1527 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1528 'chunks' => array_values( $chunks ),
1529 'metadata' => array(),
1530 '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'),
1531 );
1532 }
1533
1534 /**
1535 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1536 * Works for both WordPress DB and Pinecone entries.
1537 */
1538 public function ajax_mxchat_save_entry_content() {
1539 check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1540
1541 if ( ! current_user_can('manage_options') ) {
1542 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1543 }
1544
1545 $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1546 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1547 $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
1548 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1549 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1550 $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
1551
1552 if ( empty($content) ) {
1553 wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
1554 }
1555
1556 // Get the embedding API key
1557 $options = get_option('mxchat_options', array());
1558 $api_key = '';
1559
1560 if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
1561 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1562 $api_key = $bot_options['api_key'] ?? '';
1563 }
1564 if ( empty($api_key) ) {
1565 $api_key = $options['api_key'] ?? '';
1566 }
1567
1568 if ( $data_source === 'pinecone' ) {
1569 // Pinecone branch. The WP-DB manual-entry delete below must never run here:
1570 // Pinecone ids are strings, and absint() on a digit-leading md5 hash would
1571 // yield a real (unrelated) WP row id.
1572 $raw_vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1573 $is_manual_single = empty($source_url) || strpos($source_url, '_ungrouped_') === 0;
1574 $is_manual_chunked = strpos($source_url, 'mxchat://') === 0;
1575
1576 if ( $is_manual_single || $is_manual_chunked ) {
1577 // Manual content: remove the old vectors first, then store as fresh manual
1578 // content — submit_content_to_db mints a new unique identity (manual_* id
1579 // for a single vector, an mxchat:// chunk prefix if it now chunks).
1580 if ( $is_manual_chunked ) {
1581 // Minted identity: base + chunk vectors share the md5(mxchat://...) prefix.
1582 MxChat_Utils::delete_chunks_for_url( $source_url, $bot_id );
1583 } elseif ( ! empty($raw_vector_id) && class_exists('MxChat_Pinecone_Manager') ) {
1584 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
1585 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options( $bot_id );
1586 if ( ! empty($pinecone_options['mxchat_pinecone_api_key']) && ! empty($pinecone_options['mxchat_pinecone_host']) ) {
1587 $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
1588 $raw_vector_id,
1589 $pinecone_options['mxchat_pinecone_api_key'],
1590 $pinecone_options['mxchat_pinecone_host'],
1591 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
1592 );
1593 }
1594 }
1595 $result = MxChat_Utils::submit_content_to_db( $content, '', $api_key, null, $bot_id, $content_type );
1596 } else {
1597 // URL-sourced entry: identity is md5(source_url). submit_content_to_db
1598 // handles delete-old-chunks → re-chunk → re-embed → store, and sweeps
1599 // stale chunk vectors when the content now fits in a single vector.
1600 $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, md5($source_url), $bot_id, $content_type );
1601 }
1602 } else {
1603 global $wpdb;
1604 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1605
1606 // If source_url is empty but we have an entry_id, look it up
1607 if ( empty($source_url) && $entry_id > 0 ) {
1608 $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1609 if ( $row && ! empty($row->source_url) ) {
1610 $source_url = $row->source_url;
1611 }
1612 }
1613
1614 // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1615 // so submit_content_to_db creates a replacement instead of a duplicate
1616 // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1617 $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1618 if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
1619 $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1620 // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1621 // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1622 if ( $is_legacy_manual ) {
1623 $source_url = '';
1624 }
1625 }
1626
1627 // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1628 $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1629
1630 // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1631 $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1632 }
1633
1634 if ( is_wp_error($result) ) {
1635 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1636 }
1637
1638 wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
1639 }
1640
1641 public function mxchat_get_pdf_processing_status($pdf_url) {
1642 $pdf_url = esc_url_raw($pdf_url);
1643 $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
1644
1645 if (!$status || !is_array($status)) {
1646 return false;
1647 }
1648
1649 // Check for stalled processing (no updates for 5 minutes)
1650 if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
1651 $status['status'] = 'error';
1652 $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
1653
1654 // Save the updated status
1655 set_transient(
1656 sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
1657 array_map('sanitize_text_field', $status),
1658 DAY_IN_SECONDS
1659 );
1660 }
1661
1662 $result = array(
1663 'total_pages' => absint($status['total_pages']),
1664 'processed_pages' => absint($status['processed_pages']),
1665 'failed_pages' => absint($status['failed_pages'] ?? 0),
1666 'percentage' => ($status['total_pages'] > 0)
1667 ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
1668 : 0,
1669 'status' => sanitize_text_field($status['status']),
1670 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1671 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
1672 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
1673 );
1674
1675 // Add error message if present
1676 if (isset($status['error']) && !empty($status['error'])) {
1677 $result['error'] = sanitize_text_field($status['error']);
1678 }
1679
1680 return $result;
1681 }
1682
1683
1684 public function mxchat_handle_sitemap_submission() {
1685 // Check if the form was submitted and verify permissions
1686 if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
1687 wp_die(esc_html__('Unauthorized access', 'mxchat'));
1688 }
1689
1690 // Verify nonce
1691 check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
1692
1693 // Validate URL
1694 if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
1695 set_transient('mxchat_admin_notice_error',
1696 esc_html__('Please provide a valid URL.', 'mxchat'),
1697 30
1698 );
1699 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1700 exit;
1701 }
1702
1703 $submitted_url = esc_url_raw($_POST['sitemap_url']);
1704
1705 // Convert Google Drive sharing URLs to direct download URLs
1706 if ( strpos($submitted_url, 'drive.google.com') !== false ) {
1707 $file_id = '';
1708 if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
1709 $file_id = $m[1];
1710 } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
1711 $file_id = $m[1];
1712 }
1713 if ( ! empty($file_id) ) {
1714 $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
1715 }
1716 }
1717
1718 // Get bot_id from form submission
1719 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1720
1721 // Get bot-specific options and validate the embedding decision —
1722 // custom-provider-aware (plan cbd5fd).
1723 $bot_options = $this->get_bot_options($bot_id);
1724 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1725
1726 $preflight = MxChat_Utils::embedding_preflight($options);
1727 if (!$preflight['ok']) {
1728 set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
1729 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1730 exit;
1731 }
1732 $api_key = $preflight['api_key'];
1733
1734 // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1735 // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1736 // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1737 // from the site's own media library, which route through this same call).
1738 // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1739 // the browser-only Accept-Language fingerprint is dropped so it stays
1740 // coherent with a bot identity.
1741 $response = wp_remote_get($submitted_url, array(
1742 'timeout' => 30,
1743 'sslverify' => false,
1744 'user-agent' => mxchat_ingest_user_agent(),
1745 'headers' => array(
1746 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1747 ),
1748 ));
1749
1750 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1751 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1752 set_transient('mxchat_admin_notice_error',
1753 sprintf(
1754 esc_html__('Failed to fetch the URL: %s', 'mxchat'),
1755 esc_html($error_message)
1756 ),
1757 30
1758 );
1759 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1760 exit;
1761 }
1762
1763 $content_type = wp_remote_retrieve_header($response, 'content-type');
1764 $body_content = wp_remote_retrieve_body($response);
1765
1766 if (empty($body_content)) {
1767 set_transient('mxchat_admin_notice_error',
1768 esc_html__('Empty response received from URL.', 'mxchat'),
1769 30
1770 );
1771 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1772 exit;
1773 }
1774
1775 // Handle PDF URL
1776 if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
1777 $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
1778
1779 if ($result === 'queued') {
1780 set_transient('mxchat_admin_notice_success',
1781 esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
1782 30
1783 );
1784 } else {
1785 set_transient('mxchat_admin_notice_error',
1786 esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
1787 30
1788 );
1789 }
1790
1791 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1792 exit;
1793 }
1794
1795 // Handle Sitemap XML
1796 if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
1797 libxml_use_internal_errors(true);
1798 $xml = simplexml_load_string($body_content);
1799 $xml_errors = libxml_get_errors();
1800 libxml_clear_errors();
1801
1802 if ($xml === false || !empty($xml_errors)) {
1803 set_transient('mxchat_admin_notice_error',
1804 esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
1805 30
1806 );
1807 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1808 exit;
1809 }
1810
1811 $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
1812
1813 if ($result === 'queued') {
1814 set_transient('mxchat_admin_notice_success',
1815 esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1816 30
1817 );
1818 } else {
1819 // Surface the reason the handler already computed (embedding pre-flight,
1820 // empty sitemap, queue failure). The old message pointed at the status
1821 // area, which is empty on this path — nothing was ever queued.
1822 if (is_string($result) && $result !== '') {
1823 set_transient('mxchat_admin_notice_error',
1824 esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result),
1825 30
1826 );
1827 } else {
1828 set_transient('mxchat_admin_notice_error',
1829 esc_html__('Failed to queue sitemap processing.', 'mxchat'),
1830 30
1831 );
1832 }
1833 }
1834
1835 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1836 exit;
1837 }
1838
1839 // Handle Regular URL (single page)
1840 $page_content = $this->mxchat_extract_main_content($body_content);
1841 $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
1842
1843 //error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
1844 //error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
1845 //error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
1846 //error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
1847
1848 if (empty($sanitized_content)) {
1849 set_transient('mxchat_admin_notice_error',
1850 esc_html__('No valid content found on the provided URL.', 'mxchat'),
1851 30
1852 );
1853 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1854 exit;
1855 }
1856
1857 // For single URLs, process immediately using submit_content_to_db
1858 // This handles chunking automatically for large content
1859 $db_result = MxChat_Utils::submit_content_to_db(
1860 $sanitized_content,
1861 $submitted_url,
1862 $api_key,
1863 null,
1864 $bot_id,
1865 'url' // content_type
1866 );
1867
1868 if (is_wp_error($db_result)) {
1869 $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1870 set_transient('mxchat_admin_notice_error', $error_message, 30);
1871 } else {
1872 $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1873 set_transient('mxchat_admin_notice_success', $success_message, 30);
1874 }
1875
1876 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1877 exit;
1878 }
1879
1880
1881 public function mxchat_get_single_url_status() {
1882 $status = get_transient('mxchat_single_url_status');
1883 if (!$status) {
1884 return null;
1885 }
1886
1887 // Add human-readable time
1888 if (isset($status['timestamp'])) {
1889 $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1890 }
1891
1892 return $status;
1893 }
1894
1895 public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1896 if (!current_user_can('manage_options')) {
1897 return false;
1898 }
1899
1900 try {
1901 $sitemap_url = esc_url_raw($sitemap_url);
1902
1903 if (!$xml || !is_object($xml)) {
1904 throw new Exception(__('Invalid XML object provided', 'mxchat'));
1905 }
1906
1907 // Get bot-specific embedding API for validation
1908 $bot_options = $this->get_bot_options($bot_id);
1909 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1910
1911 // Test the embedding API before processing
1912 $test_phrase = "Test embedding generation for MxChat";
1913 $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1914
1915 if (is_string($test_result)) {
1916 throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1917 }
1918
1919 if (!is_array($test_result)) {
1920 throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1921 }
1922
1923 // Extract URLs from sitemap
1924 $urls = array();
1925 foreach ($xml->url as $url_element) {
1926 $url = esc_url_raw((string)$url_element->loc);
1927 if ($url) {
1928 $urls[] = array('url' => $url);
1929 }
1930 }
1931
1932 $total_urls = count($urls);
1933
1934 if ($total_urls < 1) {
1935 throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1936 }
1937
1938 // Create unique queue ID
1939 $queue_id = 'sitemap_' . md5($sitemap_url . time());
1940
1941 // Add URLs to queue
1942 $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1943
1944 if ($queued_count === 0) {
1945 throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1946 }
1947
1948 // Store queue metadata
1949 $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1950 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1951 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1952 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1953 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1954
1955 // Store queue ID in transient for status tracking
1956 set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
1957 set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
1958
1959 return 'queued';
1960
1961 } catch (Exception $e) {
1962 $error_message = $e->getMessage();
1963 //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
1964
1965 return $error_message;
1966 }
1967
1968 }
1969
1970 /**
1971 * Remove shortcode tags but preserve the content inside them
1972 * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
1973 *
1974 * @param string $content The content containing shortcodes
1975 * @return string Content with shortcode tags removed but inner content preserved
1976 */
1977 /**
1978 * Single-pass HTML entity decode for text entering the knowledge base.
1979 * The corpus should hold what a human reads: a stored `&amp;` consumes
1980 * extra tokens, distorts the vector away from the form a visitor's
1981 * question uses, and can be quoted back verbatim in an answer.
1982 * Deliberately NOT looped to a fixed point — a stored `&amp;amp;` is a
1983 * legitimate literal `&amp;` and must not collapse further (data loss).
1984 * UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly
1985 * paths call this at their output points so the treatment cannot drift.
1986 * (Plan d2c92e.)
1987 */
1988 private function mxchat_decode_entities_for_indexing($text) {
1989 return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1990 }
1991
1992 /**
1993 * Price lines for a product's indexed text, pinned to the store's BASE currency.
1994 *
1995 * The four product assembly paths each used to call get_woocommerce_currency_symbol()
1996 * with no argument, which resolves the currency active on the CURRENT request.
1997 * Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per
1998 * request, so whichever currency the store happened to be serving when an import ran
1999 * was frozen into every product it indexed. The amounts have the mirror problem: the
2000 * woocommerce_product_get_* filters convert prices in the 'view' context but not in
2001 * 'edit', so a converted amount could be paired with an unconverted symbol and produce
2002 * a price that is not merely wrong but incoherent.
2003 *
2004 * Base currency option + 'edit' context makes both halves agree and makes the output
2005 * independent of when the import ran. The currency CODE is emitted alongside the symbol
2006 * so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols.
2007 * (Plan 7403ec.)
2008 */
2009 private function mxchat_product_price_lines($product) {
2010 if (!is_object($product) || !method_exists($product, 'get_regular_price')) {
2011 return '';
2012 }
2013
2014 $currency = get_option('woocommerce_currency');
2015 $currency = is_string($currency) ? trim($currency) : '';
2016 $symbol = ($currency !== '')
2017 ? get_woocommerce_currency_symbol($currency)
2018 : get_woocommerce_currency_symbol();
2019 $symbol = $this->mxchat_decode_entities_for_indexing($symbol);
2020
2021 $regular_price = $product->get_regular_price('edit');
2022 $sale_price = $product->get_sale_price('edit');
2023 $price = $product->get_price('edit');
2024
2025 $lines = '';
2026
2027 if (!empty($regular_price)) {
2028 $lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n";
2029 } elseif (!empty($price)) {
2030 $lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n";
2031 }
2032
2033 if (!empty($sale_price) && $sale_price !== $regular_price) {
2034 $lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n";
2035 }
2036
2037 if ($product->is_type('variable')) {
2038 list($min_price, $max_price) = $this->mxchat_variation_price_range($product);
2039 if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) {
2040 $lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol)
2041 . " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n";
2042 }
2043 }
2044
2045 return $lines;
2046 }
2047
2048 /**
2049 * One indexed price amount, labelled with its currency code.
2050 *
2051 * "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol
2052 * is kept so a quoted price still reads naturally. Falls back to the old symbol-only
2053 * shape when WooCommerce has no base currency configured, and drops the parenthetical
2054 * when the symbol is absent or IS the code (several currencies have no distinct glyph).
2055 */
2056 private function mxchat_format_indexed_price($amount, $currency, $symbol) {
2057 $amount = (string) $amount;
2058
2059 if ($currency === '') {
2060 return $symbol . $amount;
2061 }
2062
2063 if ($symbol === '' || $symbol === $currency) {
2064 return $currency . ' ' . $amount;
2065 }
2066
2067 return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')';
2068 }
2069
2070 /**
2071 * Min/max variation price read from the variations themselves in 'edit' context.
2072 *
2073 * get_variation_price() reads WooCommerce's display price cache, which multi-currency
2074 * plugins populate with converted values — the same defect the rest of this helper
2075 * exists to remove. Returns raw stored strings (not floats) so the indexed text keeps
2076 * the store's own price formatting, and (null, null) when no variation carries a price.
2077 */
2078 private function mxchat_variation_price_range($product) {
2079 $min_raw = null;
2080 $max_raw = null;
2081 $min_val = null;
2082 $max_val = null;
2083
2084 $children = method_exists($product, 'get_children') ? $product->get_children() : array();
2085
2086 foreach ($children as $child_id) {
2087 $variation = wc_get_product($child_id);
2088 if (!$variation) {
2089 continue;
2090 }
2091 $raw = $variation->get_price('edit');
2092 if ($raw === '' || $raw === null) {
2093 continue;
2094 }
2095 $val = (float) $raw;
2096 if ($min_val === null || $val < $min_val) {
2097 $min_val = $val;
2098 $min_raw = $raw;
2099 }
2100 if ($max_val === null || $val > $max_val) {
2101 $max_val = $val;
2102 $max_raw = $raw;
2103 }
2104 }
2105
2106 return array($min_raw, $max_raw);
2107 }
2108
2109 private function strip_shortcode_tags_preserve_content($content) {
2110 // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
2111 // Content between tags is inherently preserved since only brackets are targeted
2112 $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
2113 return ($result !== null) ? $result : $content;
2114 }
2115
2116 public function mxchat_sanitize_content_for_api($content) {
2117 //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
2118
2119 // Remove shortcode tags but PRESERVE content inside them
2120 $content = $this->strip_shortcode_tags_preserve_content($content);
2121
2122 // Remove script, style tags, and HTML comments
2123 $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
2124 $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
2125 $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
2126
2127 // Remove all HTML tags and decode HTML entities
2128 $content = wp_strip_all_tags($content);
2129 $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
2130
2131 // Normalize whitespace but preserve paragraph breaks
2132 // First, normalize line endings to \n
2133 $content = str_replace(["\r\n", "\r"], "\n", $content);
2134 // Replace multiple spaces/tabs with single space, but preserve newlines
2135 $content = preg_replace('/[ \t]+/', ' ', $content);
2136 // Replace 3+ newlines with 2 newlines (max 2 blank lines)
2137 $content = preg_replace('/\n{3,}/', "\n\n", $content);
2138 // Trim each line
2139 $lines = explode("\n", $content);
2140 $lines = array_map('trim', $lines);
2141 $content = implode("\n", $lines);
2142 // Final trim
2143 $content = trim($content);
2144
2145 // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
2146 $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
2147
2148 // Remove NULL bytes which can cause database errors
2149 $content = str_replace("\0", "", $content);
2150
2151 // Ensure valid UTF-8 encoding
2152 $content = wp_check_invalid_utf8($content);
2153
2154 // Remove extremely long runs with no whitespace (base64 blobs, minified JS).
2155 // Counts CHARACTERS (/u), and never strips a run containing characters from a
2156 // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar —
2157 // where a normal paragraph is legitimately one unbroken run.
2158 $content = preg_replace_callback('/\S{300,}/u', function ($m) {
2159 return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' ';
2160 }, $content);
2161
2162 // Remove emoji/symbol blocks only — not the whole supplementary plane, which
2163 // also holds CJK Extension B ideographs used in real Chinese/Japanese names.
2164 // A ZWJ (U+200D) BETWEEN stripped pictographs is consumed with them, so a
2165 // family sequence like 👨‍👩‍👧 leaves no invisible zero-width residue behind
2166 // (the joiner between NON-emoji characters — Hindi conjuncts — is untouched).
2167 $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}](?:\x{200D}[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}])*/u', '', $content);
2168
2169 // There is deliberately NO catch-all character allowlist here (plan 209e57;
2170 // one existed until 3.2.20). Every genuinely dangerous byte is already gone:
2171 // control characters, null bytes, invalid UTF-8 and the emoji blocks are all
2172 // stripped above. The allowlist's only remaining effect was to damage scripts
2173 // nobody thought to enumerate — Unicode Cf (Format) was missing, so it
2174 // replaced the zero-width joiner/non-joiner with spaces and silently split
2175 // Persian words (می‌روم → می روم) and broke Hindi conjuncts (क्‍ष → क् ष).
2176 // Do not add one back; the failure mode of an allowlist is exactly this.
2177
2178 // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized,
2179 // but cut on a character boundary so a multibyte char is never split mid-sequence)
2180 $max_length = 65000; // Just under MySQL TEXT field limit
2181 if (strlen($content) > $max_length) {
2182 $content = mb_strcut($content, 0, $max_length, 'UTF-8');
2183 }
2184
2185 //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
2186 return $content;
2187 }
2188 public function mxchat_extract_main_content($html) {
2189 if (empty($html)) {
2190 return '';
2191 }
2192 try {
2193 $dom = new DOMDocument;
2194 libxml_use_internal_errors(true); // Suppress HTML parsing errors
2195 @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
2196 $xpath = new DOMXPath($dom);
2197
2198 // For debugging purposes
2199 $debugEnabled = true; // Set to true to enable debugging output
2200 $debug = function($message) use ($debugEnabled) {
2201 if ($debugEnabled) {
2202 //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
2203 }
2204 };
2205
2206 // Direct targeting for Gerow theme posts
2207 $post_text = $xpath->query('//div[contains(@class, "post-text")]');
2208 if ($post_text && $post_text->length > 0) {
2209 $debug("Found post-text directly");
2210 $content = '';
2211 foreach ($post_text as $node) {
2212 $content .= $dom->saveHTML($node);
2213 }
2214 if (!empty($content)) {
2215 $debug("Returning post-text content");
2216 return $content;
2217 }
2218 }
2219
2220 // Try to get the blog details content which contains the post-text
2221 $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
2222 if ($blog_details && $blog_details->length > 0) {
2223 $debug("Found blog-details-content");
2224 $content = '';
2225 foreach ($blog_details as $node) {
2226 $content .= $dom->saveHTML($node);
2227 }
2228 if (!empty($content)) {
2229 $debug("Returning blog-details-content");
2230 return $content;
2231 }
2232 }
2233
2234 // Try to get the article which contains the blog details
2235 $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
2236 if ($article && $article->length > 0) {
2237 $debug("Found article with blog-details-wrap");
2238 $content = '';
2239 foreach ($article as $node) {
2240 $content .= $dom->saveHTML($node);
2241 }
2242 if (!empty($content)) {
2243 $debug("Returning article content");
2244 return $content;
2245 }
2246 }
2247
2248 // Try even broader with the blog-item-wrap
2249 $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
2250 if ($blog_item && $blog_item->length > 0) {
2251 $debug("Found blog-item-wrap");
2252 $content = '';
2253 foreach ($blog_item as $node) {
2254 $content .= $dom->saveHTML($node);
2255 }
2256 if (!empty($content)) {
2257 $debug("Returning blog-item-wrap content");
2258 return $content;
2259 }
2260 }
2261
2262 // Specific Gerow theme path
2263 $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
2264 if ($gerow_path && $gerow_path->length > 0) {
2265 $debug("Found Gerow theme path to post-text");
2266 $content = '';
2267 foreach ($gerow_path as $node) {
2268 $content .= $dom->saveHTML($node);
2269 }
2270 if (!empty($content)) {
2271 $debug("Returning Gerow post-text content");
2272 return $content;
2273 }
2274 }
2275
2276 // Generic blog post selectors
2277 $selectors = [
2278 // Blog post specific selectors
2279 '//div[contains(@class, "post-text")]',
2280 '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
2281 '//div[contains(@class, "blog-details-content")]',
2282 '//article[contains(@class, "blog-details-wrap")]',
2283 '//div[contains(@class, "entry-content")]',
2284 '//div[contains(@class, "blog-content")]',
2285 '//div[contains(@class, "blog-item-wrap")]',
2286
2287 // More general content selectors
2288 '//div[contains(@class, "page__content")]',
2289 '//div[contains(@class, "elementor-widget-container")]',
2290 '//div[contains(@class, "elementor-text-editor")]',
2291 '//div[contains(@class, "elementor-widget-text-editor")]',
2292 '//*[contains(@class, "entry-content")]',
2293 '//*[contains(@class, "post-content")]',
2294 '//*[contains(@class, "article-content")]',
2295 '//*[@id="content"]',
2296 '//*[@id="main-content"]',
2297 '//section[contains(@class, "blog-area")]',
2298 '//article',
2299 '//main',
2300 '//div[contains(@class, "content")]'
2301 ];
2302
2303 // First handle Elementor content - get only leaf widget containers to avoid duplicates
2304 $debug("Checking for Elementor content");
2305 // Get widget containers that are direct children of widgets (not nested inside other widget containers)
2306 $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
2307 if ($elementor_widgets && $elementor_widgets->length > 0) {
2308 $debug("Found Elementor widgets");
2309 $seen_content = array(); // Track seen content to avoid duplicates
2310 $combined_content = '';
2311 foreach ($elementor_widgets as $widget) {
2312 $widget_content = $dom->saveHTML($widget);
2313 if (!empty($widget_content)) {
2314 // Create a hash of the content to detect duplicates
2315 $content_hash = md5($widget_content);
2316 if (!isset($seen_content[$content_hash])) {
2317 $seen_content[$content_hash] = true;
2318 $combined_content .= $widget_content;
2319 }
2320 }
2321 }
2322 if (!empty($combined_content)) {
2323 $debug("Returning Elementor content");
2324 return $combined_content;
2325 }
2326 }
2327
2328 // Try standard selectors one by one
2329 foreach ($selectors as $selector) {
2330 $debug("Trying selector: " . $selector);
2331 $nodes = $xpath->query($selector);
2332 if ($nodes && $nodes->length > 0) {
2333 $debug("Found " . $nodes->length . " matches for selector: " . $selector);
2334 // Only take the FIRST matching node to avoid duplicate content
2335 // (pages often have nested or multiple containers with same class)
2336 $content = $dom->saveHTML($nodes->item(0));
2337 if (!empty($content)) {
2338 $debug("Returning content from selector: " . $selector . " (first match only)");
2339 return $content;
2340 }
2341 }
2342 }
2343
2344 // Manual regex fallback for post-text if DOM methods fail
2345 $debug("Trying regex fallback");
2346 if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
2347 $debug("Found post-text via regex");
2348 return '<div class="post-text">' . $matches[1] . '</div>';
2349 }
2350
2351 // Try to extract the blog section as a whole
2352 $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
2353 if ($blog_section && $blog_section->length > 0) {
2354 $debug("Found blog-area section");
2355 $content = '';
2356 foreach ($blog_section as $node) {
2357 $content .= $dom->saveHTML($node);
2358 }
2359 if (!empty($content)) {
2360 $debug("Returning blog-area section content");
2361 return $content;
2362 }
2363 }
2364
2365 // Generic container selectors for non-CMS sites (like .asp pages)
2366 $debug("Trying generic container selectors");
2367 $generic_selectors = [
2368 '//div[@id="main"]',
2369 '//div[@id="wrapper"]',
2370 '//div[@id="page"]',
2371 '//div[@id="site-content"]',
2372 '//div[contains(@class, "main-content")]',
2373 '//div[contains(@class, "page-content")]',
2374 '//div[contains(@class, "site-content")]',
2375 ];
2376
2377 foreach ($generic_selectors as $selector) {
2378 $debug("Trying generic selector: " . $selector);
2379 $nodes = $xpath->query($selector);
2380 if ($nodes && $nodes->length > 0) {
2381 $content = $dom->saveHTML($nodes->item(0));
2382 if (!empty($content)) {
2383 $debug("Returning content from generic selector: " . $selector);
2384 return $content;
2385 }
2386 }
2387 }
2388
2389 // Paragraph-based content detection - find regions with substantial text
2390 $debug("Trying paragraph-based content detection");
2391 $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
2392 if ($paragraphs && $paragraphs->length >= 3) {
2393 $debug("Found " . $paragraphs->length . " substantial paragraphs");
2394 // Collect all substantial paragraphs and their content
2395 $paragraph_content = '';
2396 foreach ($paragraphs as $p) {
2397 $paragraph_content .= $dom->saveHTML($p) . "\n";
2398 }
2399 if (!empty($paragraph_content)) {
2400 $debug("Returning paragraph-based content");
2401 return $paragraph_content;
2402 }
2403 }
2404
2405 // Improved body fallback - strip nav/header/footer elements first
2406 $debug("Using improved body fallback");
2407 $body = $dom->getElementsByTagName('body');
2408 if ($body->length > 0) {
2409 // Clone the body to avoid modifying the original DOM
2410 $body_clone = $body->item(0)->cloneNode(true);
2411
2412 // Remove common non-content elements by tag name
2413 $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
2414 foreach ($remove_tags as $tag) {
2415 $elements = $body_clone->getElementsByTagName($tag);
2416 // Iterate backwards to safely remove elements
2417 for ($i = $elements->length - 1; $i >= 0; $i--) {
2418 $el = $elements->item($i);
2419 if ($el && $el->parentNode) {
2420 $el->parentNode->removeChild($el);
2421 }
2422 }
2423 }
2424
2425 // Remove elements with common non-content class names using XPath on the cloned body
2426 $temp_dom = new DOMDocument();
2427 @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
2428 $temp_xpath = new DOMXPath($temp_dom);
2429
2430 $remove_class_patterns = [
2431 '//*[contains(@class, "nav")]',
2432 '//*[contains(@class, "menu")]',
2433 '//*[contains(@class, "sidebar")]',
2434 '//*[contains(@class, "footer")]',
2435 '//*[contains(@class, "header")]',
2436 '//*[contains(@id, "nav")]',
2437 '//*[contains(@id, "menu")]',
2438 '//*[contains(@id, "sidebar")]',
2439 '//*[contains(@id, "footer")]',
2440 '//*[contains(@id, "header")]',
2441 ];
2442
2443 foreach ($remove_class_patterns as $pattern) {
2444 $elements = $temp_xpath->query($pattern);
2445 if ($elements) {
2446 for ($i = $elements->length - 1; $i >= 0; $i--) {
2447 $el = $elements->item($i);
2448 if ($el && $el->parentNode) {
2449 $el->parentNode->removeChild($el);
2450 }
2451 }
2452 }
2453 }
2454
2455 $cleaned_content = $temp_dom->saveHTML();
2456 if (!empty($cleaned_content)) {
2457 $debug("Returning cleaned body content");
2458 return $cleaned_content;
2459 }
2460 }
2461
2462 // Last resort: return the original HTML
2463 $debug("Returning original HTML");
2464 return $html;
2465 } catch (Exception $e) {
2466 //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
2467 return $html; // Return original HTML if parsing fails
2468 } finally {
2469 libxml_clear_errors();
2470 }
2471 }
2472 public function mxchat_get_sitemap_processing_status($sitemap_url) {
2473 $sitemap_url = esc_url_raw($sitemap_url);
2474 $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
2475 $status = get_transient($status_key);
2476
2477 if (!$status || !is_array($status)) {
2478 return false;
2479 }
2480
2481 // Auto-complete check: if all URLs are processed but status isn't complete
2482 if (isset($status['processed_urls']) && isset($status['total_urls']) &&
2483 $status['processed_urls'] >= $status['total_urls'] &&
2484 isset($status['status']) && $status['status'] !== 'complete' &&
2485 $status['status'] !== 'error') {
2486
2487 // Mark as complete
2488 $status['status'] = 'complete';
2489 $status['processed_urls'] = $status['total_urls']; // Ensure exact match
2490
2491 // Update the transient with the corrected status
2492 set_transient($status_key, $status, DAY_IN_SECONDS);
2493 }
2494
2495 return array(
2496 'total_urls' => absint($status['total_urls']),
2497 'processed_urls' => absint($status['processed_urls']),
2498 'failed_urls' => absint($status['failed_urls'] ?? 0),
2499 'percentage' => ($status['total_urls'] > 0)
2500 ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
2501 : 0,
2502 'status' => sanitize_text_field($status['status']),
2503 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
2504 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
2505 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
2506 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
2507 );
2508 }
2509
2510 public function mxchat_ajax_get_status_updates() {
2511 try {
2512 // Verify the request
2513 check_ajax_referer('mxchat_status_nonce', 'nonce');
2514
2515 // Get active queue IDs
2516 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2517 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2518
2519 $sitemap_status = false;
2520 $pdf_status = false;
2521
2522 // Get sitemap queue status
2523 if ($sitemap_queue_id) {
2524 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2525 }
2526
2527 // Get PDF queue status
2528 if ($pdf_queue_id) {
2529 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2530 }
2531
2532 $is_active_processing =
2533 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2534 ($pdf_status && $pdf_status['status'] === 'processing');
2535
2536 // Return JSON response with the status data
2537 wp_send_json(array(
2538 'pdf_status' => $pdf_status,
2539 'sitemap_status' => $sitemap_status,
2540 'is_processing' => $is_active_processing,
2541 'sitemap_queue_id' => $sitemap_queue_id,
2542 'pdf_queue_id' => $pdf_queue_id
2543 ));
2544
2545 } catch (Exception $e) {
2546 //error_log('MxChat Status Update Error: ' . $e->getMessage());
2547
2548 wp_send_json_error(array(
2549 'message' => 'Error getting status updates: ' . $e->getMessage(),
2550 'status' => 'error'
2551 ));
2552 }
2553 }
2554
2555 /**
2556 * Helper function to get queue status data
2557 */
2558 private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
2559 global $wpdb;
2560 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2561
2562 // Get counts by status
2563 $counts = $wpdb->get_results($wpdb->prepare(
2564 "SELECT status, COUNT(*) as count
2565 FROM $table_name
2566 WHERE queue_id = %s
2567 GROUP BY status",
2568 $queue_id
2569 ), OBJECT_K);
2570
2571 $total = 0;
2572 $completed = 0;
2573 $failed = 0;
2574 $processing = 0;
2575 $pending = 0;
2576
2577 foreach ($counts as $status => $data) {
2578 $count = absint($data->count);
2579 $total += $count;
2580
2581 switch ($status) {
2582 case 'completed':
2583 $completed = $count;
2584 break;
2585 case 'failed':
2586 $failed = $count;
2587 break;
2588 case 'processing':
2589 $processing = $count;
2590 break;
2591 case 'pending':
2592 $pending = $count;
2593 break;
2594 }
2595 }
2596
2597 if ($total === 0) {
2598 return false;
2599 }
2600
2601 // Calculate percentage
2602 $percentage = round((($completed + $failed) / $total) * 100);
2603
2604 // Get failed items details (limit to 50)
2605 $failed_items = array();
2606 if ($failed > 0) {
2607 $failed_results = $wpdb->get_results($wpdb->prepare(
2608 "SELECT item_type, item_data, error_message, attempts, completed_at
2609 FROM $table_name
2610 WHERE queue_id = %s
2611 AND status = 'failed'
2612 AND attempts >= max_attempts
2613 ORDER BY id DESC
2614 LIMIT 50",
2615 $queue_id
2616 ));
2617
2618 foreach ($failed_results as $item) {
2619 $data = json_decode($item->item_data, true);
2620 $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
2621
2622 $failed_items[] = array(
2623 'url' => $url,
2624 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
2625 'error' => $item->error_message,
2626 'retries' => $item->attempts,
2627 'time' => strtotime($item->completed_at)
2628 );
2629 }
2630 }
2631
2632 // Get queue metadata
2633 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
2634
2635 // Determine if queue is complete
2636 $is_complete = ($pending === 0 && $processing === 0);
2637
2638 // Get last update time
2639 $last_update = $wpdb->get_var($wpdb->prepare(
2640 "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
2641 FROM $table_name
2642 WHERE queue_id = %s",
2643 $queue_id
2644 ));
2645
2646 $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
2647
2648 // Format based on type
2649 if ($type === 'pdf') {
2650 return array(
2651 'total_pages' => $total,
2652 'processed_pages' => $completed + $failed,
2653 'failed_pages' => $failed,
2654 'percentage' => $percentage,
2655 'status' => $is_complete ? 'complete' : 'processing',
2656 'last_update' => $last_update_text,
2657 'failed_pages_list' => $failed_items,
2658 'pdf_url' => $source_url,
2659 'queue_id' => $queue_id
2660 );
2661 } else {
2662 return array(
2663 'total_urls' => $total,
2664 'processed_urls' => $completed + $failed,
2665 'failed_urls' => $failed,
2666 'percentage' => $percentage,
2667 'status' => $is_complete ? 'complete' : 'processing',
2668 'last_update' => $last_update_text,
2669 'failed_urls_list' => $failed_items,
2670 'sitemap_url' => $source_url,
2671 'queue_id' => $queue_id
2672 );
2673 }
2674 }
2675
2676 /**
2677 * Public method to get processing status for both sitemap and PDF queues
2678 * Used by admin pages to display processing status
2679 *
2680 * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
2681 */
2682 public function mxchat_get_processing_statuses() {
2683 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2684 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2685
2686 $sitemap_status = false;
2687 $pdf_status = false;
2688
2689 if ($sitemap_queue_id) {
2690 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2691 }
2692
2693 if ($pdf_queue_id) {
2694 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2695 }
2696
2697 $is_processing =
2698 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2699 ($pdf_status && $pdf_status['status'] === 'processing');
2700
2701 return array(
2702 'sitemap_status' => $sitemap_status,
2703 'pdf_status' => $pdf_status,
2704 'is_processing' => $is_processing
2705 );
2706 }
2707
2708 /**
2709 * AJAX handler to get recent knowledge entries for real-time table updates
2710 * UPDATED: Now supports both WordPress DB and Pinecone data sources
2711 */
2712 public function ajax_mxchat_get_recent_entries() {
2713 check_ajax_referer('mxchat_entries_nonce', 'nonce');
2714
2715 if (!current_user_can('manage_options')) {
2716 wp_send_json_error(array('message' => 'Unauthorized'));
2717 return;
2718 }
2719
2720 global $wpdb;
2721 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2722
2723 // Get parameters
2724 $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
2725 $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
2726 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2727
2728 // Check if Pinecone is enabled for this bot
2729 $pinecone_manager = $this->mxchat_get_pinecone_manager();
2730 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2731 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2732 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2733
2734 if ($use_pinecone && $has_pinecone_api) {
2735 // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
2736 // Use mxchat_fetch_pinecone_records which returns total_unique_entries
2737 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
2738 $total_count = $records['total'] ?? 0;
2739
2740 // For Pinecone, we don't return individual entries during polling
2741 // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
2742 // We just return the updated count. Over 500 vectors that is the live
2743 // index / namespace vector count climbing during an import (plan dd6e10).
2744 wp_send_json_success(array(
2745 'entries' => array(),
2746 'total_count' => absint($total_count),
2747 'count_unit' => $records['count_unit'] ?? '',
2748 'max_id' => $last_id,
2749 'data_source' => 'pinecone'
2750 ));
2751 return;
2752 }
2753
2754 // WORDPRESS DB DATA SOURCE
2755 // Build query to get entries newer than last_id
2756 $where_clauses = array('1=1');
2757 $where_values = array();
2758
2759 if ($last_id > 0) {
2760 $where_clauses[] = 'id > %d';
2761 $where_values[] = $last_id;
2762 }
2763
2764 // Note: WordPress DB table doesn't have bot_id column
2765 // Multi-bot filtering is handled via Pinecone namespaces
2766
2767 $where_sql = implode(' AND ', $where_clauses);
2768
2769 // Get recent entries
2770 $query = "SELECT id, article_content, source_url, timestamp
2771 FROM $table_name
2772 WHERE $where_sql
2773 ORDER BY id DESC
2774 LIMIT %d";
2775
2776 $where_values[] = $limit;
2777
2778 $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
2779
2780 // Get total count of GROUPED entries (by source_url) - matches pagination display
2781 // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
2782 $total_count = $wpdb->get_var(
2783 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2784 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2785 );
2786
2787 // Format entries for response
2788 $formatted_entries = array();
2789 $preview_length = 150;
2790 foreach ($entries as $entry) {
2791 // Parse chunk metadata using the proper chunker method (same as initial page load)
2792 if (class_exists('MxChat_Chunker')) {
2793 $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
2794 $display_content = $chunk_meta['text'];
2795 $chunk_metadata = $chunk_meta['metadata'];
2796 } else {
2797 $display_content = $entry->article_content;
2798 $chunk_metadata = array();
2799 }
2800
2801 $content_preview = mb_strlen($display_content) > $preview_length
2802 ? mb_substr($display_content, 0, $preview_length) . '...'
2803 : $display_content;
2804
2805 $formatted_entries[] = array(
2806 'id' => $entry->id,
2807 'preview' => esc_html($content_preview),
2808 'full_content' => wp_kses_post(wpautop($display_content)),
2809 'content_length' => mb_strlen($display_content),
2810 'preview_length' => $preview_length,
2811 'source_url' => $entry->source_url,
2812 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2813 'chunk_metadata' => $chunk_metadata,
2814 'bot_id' => $entry->bot_id ?? 'default',
2815 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2816 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2817 );
2818 }
2819
2820 wp_send_json_success(array(
2821 'entries' => $formatted_entries,
2822 'total_count' => absint($total_count),
2823 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
2824 'data_source' => 'wordpress'
2825 ));
2826 }
2827
2828 /**
2829 * Get Pinecone total count from stats API
2830 * Helper function for ajax_mxchat_get_recent_entries
2831 */
2832 private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
2833 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2834 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2835 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2836
2837 if (empty($api_key) || empty($host)) {
2838 return 0;
2839 }
2840
2841 // Document index (plan 362c31).
2842 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
2843 return MxChat_Pinecone_Documents::count(MxChat_Pinecone_Documents::cfg_from_options($pinecone_options));
2844 }
2845
2846 try {
2847 $stats_url = "https://{$host}/describe_index_stats";
2848
2849 $response = wp_remote_post($stats_url, array(
2850 'headers' => array(
2851 'Api-Key' => $api_key,
2852 'Content-Type' => 'application/json'
2853 ),
2854 'body' => '{}',
2855 'timeout' => 10
2856 ));
2857
2858 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2859 $body = wp_remote_retrieve_body($response);
2860 $stats_data = json_decode($body, true);
2861
2862 // If namespace is specified, get count from that specific namespace
2863 if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
2864 return intval($stats_data['namespaces'][$namespace]['vectorCount']);
2865 }
2866
2867 // If no namespace specified or namespace not found in response, use total
2868 return intval($stats_data['totalVectorCount'] ?? 0);
2869 }
2870
2871 return 0;
2872
2873 } catch (Exception $e) {
2874 return 0;
2875 }
2876 }
2877
2878 /**
2879 * AJAX handler to refresh Pinecone entries table via AJAX
2880 * Returns the table HTML for updating the UI without a full page reload
2881 */
2882 public function ajax_mxchat_refresh_pinecone_entries() {
2883 check_ajax_referer('mxchat_entries_nonce', 'nonce');
2884
2885 if (!current_user_can('manage_options')) {
2886 wp_send_json_error(array('message' => 'Unauthorized'));
2887 return;
2888 }
2889
2890 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2891 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2892 $per_page = 25;
2893 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2894 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2895
2896 // Get Pinecone manager and options
2897 $pinecone_manager = $this->mxchat_get_pinecone_manager();
2898 if (!$pinecone_manager) {
2899 wp_send_json_error(array('message' => 'Pinecone manager not available'));
2900 return;
2901 }
2902
2903 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
2904 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2905 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2906
2907 if (!$use_pinecone || empty($pinecone_api_key)) {
2908 wp_send_json_error(array('message' => 'Pinecone not configured'));
2909 return;
2910 }
2911
2912 // Fetch records from Pinecone
2913 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
2914 $prompts = $records['data'] ?? array();
2915 $total_records = $records['total'] ?? 0;
2916
2917 // Preprocess Pinecone records — set chunk_metadata and display_content
2918 // (matches admin-knowledge-page.php preprocessing)
2919 foreach ($prompts as $prompt) {
2920 if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
2921 $prompt->chunk_metadata = array(
2922 'chunk_index' => intval($prompt->chunk_index),
2923 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
2924 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
2925 'source_url' => $prompt->source_url ?? ''
2926 );
2927 $prompt->display_content = $prompt->article_content;
2928 } else {
2929 $prompt->chunk_metadata = array();
2930 $prompt->display_content = $prompt->article_content ?? '';
2931 }
2932 }
2933
2934 // Group prompts by source_url
2935 $grouped_prompts = array();
2936 foreach ($prompts as $prompt) {
2937 $source_url = '';
2938 if (!empty($prompt->chunk_metadata['source_url'])) {
2939 $source_url = $prompt->chunk_metadata['source_url'];
2940 } elseif (!empty($prompt->source_url)) {
2941 $source_url = $prompt->source_url;
2942 }
2943
2944 if (!empty($source_url)) {
2945 if (!isset($grouped_prompts[$source_url])) {
2946 $grouped_prompts[$source_url] = array();
2947 }
2948 $grouped_prompts[$source_url][] = $prompt;
2949 } else {
2950 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2951 }
2952 }
2953
2954 // Sort each group by chunk_index
2955 foreach ($grouped_prompts as $source_url => &$group) {
2956 usort($group, function($a, $b) {
2957 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2958 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2959 return $index_a - $index_b;
2960 });
2961 }
2962 unset($group);
2963
2964 // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
2965 ob_start();
2966 $display_index = 0;
2967 $current_page = $page;
2968 $data_source = 'pinecone';
2969 $current_bot_id = $bot_id;
2970 $preview_length = 150;
2971
2972 if (empty($grouped_prompts)) {
2973 echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2974 esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
2975 echo '</td></tr>';
2976 } else {
2977 foreach ($grouped_prompts as $source_url => $group) {
2978 $chunk_count = count($group);
2979 $first_prompt = $group[0];
2980 $display_index++;
2981
2982 if ($chunk_count > 1) {
2983 // Multiple chunks - show grouped row with expand button
2984 $group_id = 'group-' . md5($source_url);
2985 ?>
2986 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2987 class="mxchat-chunk-group-header"
2988 data-source="<?php echo esc_attr($data_source); ?>"
2989 data-group-id="<?php echo esc_attr($group_id); ?>"
2990 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2991 <td style="padding: 12px 16px; text-align: center;">
2992 <input type="checkbox"
2993 class="mxchat-entry-checkbox"
2994 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2995 data-source="<?php echo esc_attr($data_source); ?>"
2996 data-source-url="<?php echo esc_attr($source_url); ?>"
2997 data-is-group="true"
2998 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2999 </td>
3000 <td style="padding: 12px 16px; font-size: 13px;">
3001 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3002 </td>
3003 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3004 <div class="mxchat-chunk-group-info">
3005 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3006 <span class="dashicons dashicons-arrow-right-alt2"></span>
3007 </button>
3008 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3009 <span class="mxchat-chunk-preview">
3010 <?php
3011 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
3012 $content_preview = mb_substr($parent_content, 0, 100);
3013 echo esc_html($content_preview . '...');
3014 ?>
3015 </span>
3016 </div>
3017 </td>
3018 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3019 <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3020 <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3021 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3022 <?php esc_html_e('View Source', 'mxchat'); ?>
3023 </a>
3024 <?php else : ?>
3025 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3026 <?php endif; ?>
3027 </td>
3028 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3029 <button type="button"
3030 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3031 data-source-url="<?php echo esc_attr($source_url); ?>"
3032 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3033 data-data-source="<?php echo esc_attr($data_source); ?>"
3034 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3035 data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3036 title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3037 <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3038 </button>
3039 <button type="button"
3040 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3041 data-source-url="<?php echo esc_attr($source_url); ?>"
3042 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3043 data-data-source="<?php echo esc_attr($data_source); ?>"
3044 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3045 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3046 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3047 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3048 </button>
3049 <button type="button"
3050 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3051 data-source-url="<?php echo esc_attr($source_url); ?>"
3052 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3053 data-data-source="<?php echo esc_attr($data_source); ?>"
3054 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3055 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3056 style="color: var(--mxch-error);"
3057 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3058 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3059 </button>
3060 </td>
3061 </tr>
3062 <?php
3063 // Render hidden chunk rows
3064 foreach ($group as $chunk_index => $chunk) {
3065 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3066 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3067 $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->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($chunk->id); ?>"
3073 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3074 data-source="<?php echo esc_attr($data_source); ?>"
3075 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3076 <td style="padding: 12px 16px; text-align: center;">
3077 <!-- Checkbox column placeholder for chunks (managed by group) -->
3078 </td>
3079 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3080 <!-- Hidden ID column for chunks -->
3081 </td>
3082 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3083 <div class="mxchat-accordion-wrapper">
3084 <div class="mxchat-content-preview">
3085 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3086 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3087 </span>
3088 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3089 <?php if (mb_strlen($content) > $preview_length) : ?>
3090 <button class="mxchat-expand-toggle" type="button">
3091 <span class="dashicons dashicons-arrow-down-alt2"></span>
3092 </button>
3093 <?php endif; ?>
3094 </div>
3095 <div class="mxchat-content-full" style="display: none;">
3096 <div class="content-view">
3097 <?php
3098 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3099 echo '<div dir="rtl" lang="he" class="rtl-content">';
3100 echo wp_kses_post(wpautop($content));
3101 echo '</div>';
3102 } else {
3103 echo wp_kses_post(wpautop($content));
3104 }
3105 ?>
3106 </div>
3107 </div>
3108 </div>
3109 </td>
3110 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3111 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3112 </td>
3113 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3114 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3115 </td>
3116 </tr>
3117 <?php
3118 }
3119 } else {
3120 // Single entry - display normally with accordion
3121 $prompt = $first_prompt;
3122 $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
3123 $content_preview = mb_strlen($content) > $preview_length
3124 ? mb_substr($content, 0, $preview_length) . '...'
3125 : $content;
3126 ?>
3127 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3128 data-source="<?php echo esc_attr($data_source); ?>"
3129 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
3130 <td style="padding: 12px 16px; text-align: center;">
3131 <input type="checkbox"
3132 class="mxchat-entry-checkbox"
3133 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3134 data-source="<?php echo esc_attr($data_source); ?>"
3135 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3136 data-is-group="false"
3137 data-chunk-count="1">
3138 </td>
3139 <td style="padding: 12px 16px; font-size: 13px;">
3140 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3141 </td>
3142 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3143 <div class="mxchat-accordion-wrapper">
3144 <div class="mxchat-content-preview">
3145 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3146 <?php if (mb_strlen($content) > $preview_length) : ?>
3147 <button class="mxchat-expand-toggle" type="button">
3148 <span class="dashicons dashicons-arrow-down-alt2"></span>
3149 </button>
3150 <?php endif; ?>
3151 </div>
3152 <div class="mxchat-content-full" style="display: none;">
3153 <div class="content-view">
3154 <?php
3155 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3156 echo '<div dir="rtl" lang="he" class="rtl-content">';
3157 echo wp_kses_post(wpautop($content));
3158 echo '</div>';
3159 } else {
3160 echo wp_kses_post(wpautop($content));
3161 }
3162 ?>
3163 </div>
3164 </div>
3165 </div>
3166 </td>
3167 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3168 <?php
3169 $actual_source = $source_url;
3170 if (strpos($source_url, '_ungrouped_') === 0) {
3171 $actual_source = $prompt->source_url ?? '';
3172 }
3173 if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3174 <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3175 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3176 <?php esc_html_e('View', 'mxchat'); ?>
3177 </a>
3178 <?php else : ?>
3179 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3180 <?php endif; ?>
3181 </td>
3182 <td style="padding: 12px 16px; white-space: nowrap;">
3183 <button type="button"
3184 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3185 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3186 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3187 data-data-source="<?php echo esc_attr($data_source); ?>"
3188 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3189 data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3190 title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3191 <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3192 </button>
3193 <button type="button"
3194 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3195 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3196 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3197 data-data-source="<?php echo esc_attr($data_source); ?>"
3198 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3199 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3200 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3201 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3202 </button>
3203 <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);">
3204 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3205 </button>
3206 </td>
3207 </tr>
3208 <?php
3209 }
3210 }
3211 }
3212 $html = ob_get_clean();
3213
3214 // Generate pagination HTML for Pinecone
3215 $total_pages = ceil($total_records / $per_page);
3216 $count_unit = $records['count_unit'] ?? '';
3217 $cursor_pagination = !empty($records['cursor_pagination']);
3218 $has_next = !empty($records['has_next']);
3219 $pagination_html = '';
3220 if ($cursor_pagination) {
3221 // Plan dd6e10: cursor path — Previous / Next only, no numbered pages.
3222 $pagination_html = self::mxchat_cursor_pagination_html($page, $has_next, $search_query, $content_type_filter);
3223 } elseif ($total_pages > 1) {
3224 $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) . '">';
3225
3226 // Previous button
3227 if ($page > 1) {
3228 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3229 }
3230
3231 // Page numbers
3232 $start_page = max(1, $page - 2);
3233 $end_page = min($total_pages, $page + 2);
3234
3235 if ($start_page > 1) {
3236 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3237 if ($start_page > 2) {
3238 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3239 }
3240 }
3241
3242 for ($i = $start_page; $i <= $end_page; $i++) {
3243 if ($i == $page) {
3244 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3245 } else {
3246 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3247 }
3248 }
3249
3250 if ($end_page < $total_pages) {
3251 if ($end_page < $total_pages - 1) {
3252 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3253 }
3254 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3255 }
3256
3257 // Next button
3258 if ($page < $total_pages) {
3259 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3260 }
3261
3262 $pagination_html .= '</div>';
3263 }
3264
3265 wp_send_json_success(array(
3266 'html' => $html,
3267 'pagination_html' => $pagination_html,
3268 'total_count' => $total_records,
3269 'count_unit' => $count_unit,
3270 'total_pages' => $total_pages,
3271 'page' => $page,
3272 'per_page' => $per_page,
3273 'data_source' => 'pinecone'
3274 ));
3275 }
3276
3277 /**
3278 * Previous / Next markup for the Pinecone cursor path (plan dd6e10).
3279 *
3280 * Pinecone's /vectors/list has no random access, so numbered pages would be a
3281 * fiction; the page number here is only the cursor step, which the manager
3282 * maps to a stored paginationToken. Used by the page template on first render
3283 * and by the AJAX paginator, so the two can never drift.
3284 */
3285 public static function mxchat_cursor_pagination_html($page, $has_next, $search_query = '', $content_type_filter = '') {
3286 $page = max(1, absint($page));
3287 if ($page <= 1 && !$has_next) {
3288 return '';
3289 }
3290 $html = '<div class="mxchat-ajax-pagination mxchat-cursor-pagination" data-current-page="' . esc_attr($page) . '" data-total-pages="" data-search="' . esc_attr($search_query) . '" data-content-type="' . esc_attr($content_type_filter) . '">';
3291 if ($page > 1) {
3292 $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3293 }
3294 $html .= '<span class="mxchat-page-current">' . esc_html($page) . '</span> ';
3295 if ($has_next) {
3296 $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3297 }
3298 $html .= '</div>';
3299 return $html;
3300 }
3301
3302 /**
3303 * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
3304 * Returns paginated entries without requiring a full page reload
3305 */
3306 public function ajax_mxchat_paginate_entries() {
3307 check_ajax_referer('mxchat_entries_nonce', 'nonce');
3308
3309 if (!current_user_can('manage_options')) {
3310 wp_send_json_error(array('message' => 'Unauthorized'));
3311 return;
3312 }
3313
3314 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
3315 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3316 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3317 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
3318 $per_page = 25;
3319
3320 // Check if Pinecone is enabled for this bot
3321 $pinecone_manager = $this->mxchat_get_pinecone_manager();
3322 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
3323 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3324 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
3325
3326 if ($use_pinecone && $has_pinecone_api) {
3327 // Delegate to Pinecone pagination handler (pass search params)
3328 $_POST['page'] = $page;
3329 $_POST['search'] = $search_query;
3330 $_POST['content_type'] = $content_type_filter;
3331 $this->ajax_mxchat_refresh_pinecone_entries();
3332 return;
3333 }
3334
3335 // WordPress DB pagination - MUST match initial page load logic exactly
3336 global $wpdb;
3337 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3338 $offset = ($page - 1) * $per_page;
3339
3340 // Build WHERE clause for search and content type filtering
3341 $where_clauses = array();
3342 $where_values = array();
3343
3344 if ($search_query) {
3345 $where_clauses[] = "article_content LIKE %s";
3346 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
3347 }
3348
3349 if ($content_type_filter) {
3350 switch ($content_type_filter) {
3351 case 'manual':
3352 $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
3353 break;
3354 case 'pdf':
3355 $where_clauses[] = "source_url LIKE '%.pdf'";
3356 break;
3357 case 'url':
3358 $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
3359 break;
3360 }
3361 }
3362
3363 $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
3364
3365 // Count grouped entries with filters applied
3366 if (!empty($where_values)) {
3367 $count_args = array_merge($where_values, $where_values);
3368 $count_query = $wpdb->prepare(
3369 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3370 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
3371 ...$count_args
3372 );
3373 $total_records = $wpdb->get_var($count_query);
3374 } else if (!empty($where_sql)) {
3375 // Content type filter only (no search), no prepared values needed
3376 $total_records = $wpdb->get_var(
3377 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3378 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
3379 );
3380 } else {
3381 // No filters
3382 $total_records = $wpdb->get_var(
3383 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3384 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
3385 );
3386 }
3387 $total_pages = ceil($total_records / $per_page);
3388
3389 // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
3390 if (!empty($where_values)) {
3391 $query_args = array_merge($where_values, array($per_page, $offset));
3392 $urls_query = $wpdb->prepare(
3393 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3394 {$where_sql}
3395 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3396 ...$query_args
3397 );
3398 } else if (!empty($where_sql)) {
3399 $urls_query = $wpdb->prepare(
3400 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3401 {$where_sql}
3402 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3403 $per_page, $offset
3404 );
3405 } else {
3406 $urls_query = $wpdb->prepare(
3407 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3408 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3409 $per_page, $offset
3410 );
3411 }
3412 $page_urls = $wpdb->get_results($urls_query);
3413
3414 // Step 2: Build list of source_urls to fetch
3415 $url_list = array();
3416 $url_order_map = array();
3417 $order_index = 0;
3418 foreach ($page_urls as $url_row) {
3419 $url = $url_row->source_url;
3420 $url_list[] = $url;
3421 $url_order_map[$url] = $order_index++;
3422 }
3423
3424 // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
3425 $prompts = array();
3426 if (!empty($url_list)) {
3427 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
3428 if ($search_query) {
3429 // Include search filter in the final fetch
3430 $prompts_query = $wpdb->prepare(
3431 "SELECT id, article_content, source_url, timestamp, role_restriction
3432 FROM {$table_name}
3433 WHERE source_url IN ($placeholders) AND article_content LIKE %s
3434 ORDER BY timestamp DESC",
3435 ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
3436 );
3437 } else {
3438 $prompts_query = $wpdb->prepare(
3439 "SELECT id, article_content, source_url, timestamp, role_restriction
3440 FROM {$table_name}
3441 WHERE source_url IN ($placeholders)
3442 ORDER BY timestamp DESC",
3443 $url_list
3444 );
3445 }
3446 $prompts = $wpdb->get_results($prompts_query);
3447 }
3448
3449 // Group prompts by source_url for chunk display
3450 $grouped_prompts = array();
3451 foreach ($prompts as $prompt) {
3452 $source_url = $prompt->source_url ?? '';
3453
3454 // Parse chunk metadata using the proper chunker method (same as initial page load)
3455 if (class_exists('MxChat_Chunker')) {
3456 $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
3457 $prompt->chunk_metadata = $chunk_meta['metadata'];
3458 $prompt->display_content = $chunk_meta['text'];
3459 } else {
3460 $prompt->chunk_metadata = array();
3461 $prompt->display_content = $prompt->article_content;
3462 }
3463
3464 if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
3465 if (!isset($grouped_prompts[$source_url])) {
3466 $grouped_prompts[$source_url] = array();
3467 }
3468 $grouped_prompts[$source_url][] = $prompt;
3469 } else {
3470 // Ungrouped entries
3471 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
3472 }
3473 }
3474
3475 // Sort groups by the original URL order (newest first)
3476 uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
3477 $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
3478 $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
3479 return $order_a - $order_b;
3480 });
3481
3482 // Sort each group internally by chunk_index
3483 foreach ($grouped_prompts as $source_url => &$group) {
3484 usort($group, function($a, $b) {
3485 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
3486 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
3487 return $index_a - $index_b;
3488 });
3489 }
3490 unset($group);
3491
3492 // Build HTML for the table rows
3493 ob_start();
3494 $display_index = 0;
3495 $current_page = $page;
3496 $data_source = 'wordpress';
3497 $current_bot_id = $bot_id;
3498 $preview_length = 150;
3499
3500 if (empty($grouped_prompts)) {
3501 echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3502 esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
3503 echo '</td></tr>';
3504 } else {
3505 foreach ($grouped_prompts as $source_url => $group) {
3506 $chunk_count = count($group);
3507 $first_prompt = $group[0];
3508 $display_index++;
3509
3510 if ($chunk_count > 1) {
3511 // Multiple chunks - show grouped row with expand button
3512 $group_id = 'group-' . md5($source_url);
3513 ?>
3514 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3515 class="mxchat-chunk-group-header"
3516 data-source="<?php echo esc_attr($data_source); ?>"
3517 data-group-id="<?php echo esc_attr($group_id); ?>"
3518 style="border-bottom: 1px solid var(--mxch-card-border);">
3519 <td style="padding: 12px 16px; text-align: center;">
3520 <input type="checkbox"
3521 class="mxchat-entry-checkbox"
3522 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3523 data-source="<?php echo esc_attr($data_source); ?>"
3524 data-source-url="<?php echo esc_attr($source_url); ?>"
3525 data-is-group="true"
3526 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3527 </td>
3528 <td style="padding: 12px 16px; font-size: 13px;">
3529 <?php echo esc_html($first_prompt->id); ?>
3530 </td>
3531 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3532 <div class="mxchat-chunk-group-info">
3533 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3534 <span class="dashicons dashicons-arrow-right-alt2"></span>
3535 </button>
3536 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3537 <span class="mxchat-chunk-preview">
3538 <?php
3539 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
3540 $content_preview = mb_substr($parent_content, 0, 100);
3541 echo esc_html($content_preview . '...');
3542 ?>
3543 </span>
3544 </div>
3545 </td>
3546 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3547 <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3548 <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3549 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3550 <?php esc_html_e('View Source', 'mxchat'); ?>
3551 </a>
3552 <?php else : ?>
3553 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3554 <?php endif; ?>
3555 </td>
3556 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3557 <?php if ($data_source !== 'pinecone') : ?>
3558 <button type="button"
3559 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3560 data-source-url="<?php echo esc_attr($source_url); ?>"
3561 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3562 data-data-source="<?php echo esc_attr($data_source); ?>"
3563 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3564 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3565 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3566 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3567 </button>
3568 <?php endif; ?>
3569 <button type="button"
3570 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3571 data-source-url="<?php echo esc_attr($source_url); ?>"
3572 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3573 data-data-source="<?php echo esc_attr($data_source); ?>"
3574 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3575 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3576 style="color: var(--mxch-error);"
3577 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3578 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3579 </button>
3580 </td>
3581 </tr>
3582 <?php
3583 // Render hidden chunk rows
3584 foreach ($group as $chunk_index => $chunk) {
3585 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3586 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3587 $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
3588 $content_preview = mb_strlen($content) > $preview_length
3589 ? mb_substr($content, 0, $preview_length) . '...'
3590 : $content;
3591 ?>
3592 <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3593 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3594 data-source="<?php echo esc_attr($data_source); ?>"
3595 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3596 <td style="padding: 12px 16px; text-align: center;">
3597 <!-- Checkbox column placeholder for chunks (managed by group) -->
3598 </td>
3599 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3600 <!-- Hidden ID column for chunks -->
3601 </td>
3602 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3603 <div class="mxchat-accordion-wrapper">
3604 <div class="mxchat-content-preview">
3605 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3606 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3607 </span>
3608 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3609 <?php if (mb_strlen($content) > $preview_length) : ?>
3610 <button class="mxchat-expand-toggle" type="button">
3611 <span class="dashicons dashicons-arrow-down-alt2"></span>
3612 </button>
3613 <?php endif; ?>
3614 </div>
3615 <div class="mxchat-content-full" style="display: none;">
3616 <div class="content-view">
3617 <?php
3618 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3619 echo '<div dir="rtl" lang="he" class="rtl-content">';
3620 echo wp_kses_post(wpautop($content));
3621 echo '</div>';
3622 } else {
3623 echo wp_kses_post(wpautop($content));
3624 }
3625 ?>
3626 </div>
3627 </div>
3628 </div>
3629 </td>
3630 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3631 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3632 </td>
3633 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3634 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3635 </td>
3636 </tr>
3637 <?php
3638 }
3639 } else {
3640 // Single entry - display normally with accordion
3641 $prompt = $first_prompt;
3642 $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
3643 $content_preview = mb_strlen($content) > $preview_length
3644 ? mb_substr($content, 0, $preview_length) . '...'
3645 : $content;
3646 ?>
3647 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3648 data-source="<?php echo esc_attr($data_source); ?>"
3649 style="border-bottom: 1px solid var(--mxch-card-border);">
3650 <td style="padding: 12px 16px; text-align: center;">
3651 <input type="checkbox"
3652 class="mxchat-entry-checkbox"
3653 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3654 data-source="<?php echo esc_attr($data_source); ?>"
3655 data-source-url="<?php echo esc_attr($source_url); ?>"
3656 data-is-group="false">
3657 </td>
3658 <td style="padding: 12px 16px; font-size: 13px;">
3659 <?php echo esc_html($prompt->id); ?>
3660 </td>
3661 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3662 <div class="mxchat-accordion-wrapper">
3663 <div class="mxchat-content-preview">
3664 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3665 <?php if (mb_strlen($content) > $preview_length) : ?>
3666 <button class="mxchat-expand-toggle" type="button">
3667 <span class="dashicons dashicons-arrow-down-alt2"></span>
3668 </button>
3669 <?php endif; ?>
3670 </div>
3671 <div class="mxchat-content-full" style="display: none;">
3672 <div class="content-view">
3673 <?php
3674 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3675 echo '<div dir="rtl" lang="he" class="rtl-content">';
3676 echo wp_kses_post(wpautop($content));
3677 echo '</div>';
3678 } else {
3679 echo wp_kses_post(wpautop($content));
3680 }
3681 ?>
3682 </div>
3683 </div>
3684 </div>
3685 </td>
3686 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3687 <?php
3688 $actual_source = $source_url;
3689 if (strpos($source_url, '_ungrouped_') === 0) {
3690 $actual_source = $prompt->source_url ?? '';
3691 }
3692 if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3693 <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3694 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3695 <?php esc_html_e('View', 'mxchat'); ?>
3696 </a>
3697 <?php else : ?>
3698 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3699 <?php endif; ?>
3700 </td>
3701 <td style="padding: 12px 16px; white-space: nowrap;">
3702 <button type="button"
3703 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3704 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3705 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3706 data-data-source="<?php echo esc_attr($data_source); ?>"
3707 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3708 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3709 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3710 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3711 </button>
3712 <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);">
3713 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3714 </button>
3715 </td>
3716 </tr>
3717 <?php
3718 }
3719 }
3720 }
3721 $html = ob_get_clean();
3722
3723 // Generate pagination HTML (include search/filter data for subsequent pages)
3724 $pagination_html = '';
3725 if ($total_pages > 1) {
3726 $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) . '">';
3727
3728 // Previous button
3729 if ($page > 1) {
3730 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3731 }
3732
3733 // Page numbers
3734 $start_page = max(1, $page - 2);
3735 $end_page = min($total_pages, $page + 2);
3736
3737 if ($start_page > 1) {
3738 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3739 if ($start_page > 2) {
3740 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3741 }
3742 }
3743
3744 for ($i = $start_page; $i <= $end_page; $i++) {
3745 if ($i == $page) {
3746 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3747 } else {
3748 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3749 }
3750 }
3751
3752 if ($end_page < $total_pages) {
3753 if ($end_page < $total_pages - 1) {
3754 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3755 }
3756 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3757 }
3758
3759 // Next button
3760 if ($page < $total_pages) {
3761 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3762 }
3763
3764 $pagination_html .= '</div>';
3765 }
3766
3767 wp_send_json_success(array(
3768 'html' => $html,
3769 'pagination_html' => $pagination_html,
3770 'total_count' => $total_records,
3771 'total_pages' => $total_pages,
3772 'page' => $page,
3773 'per_page' => $per_page,
3774 'data_source' => 'wordpress'
3775 ));
3776 }
3777
3778 /**
3779 * AJAX handler to detect available sitemaps on the site
3780 * Optimized for speed - only checks primary sitemap indexes first
3781 */
3782 public function ajax_mxchat_detect_sitemaps() {
3783 check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
3784
3785 if (!current_user_can('manage_options')) {
3786 wp_send_json_error(array('message' => 'Unauthorized'));
3787 return;
3788 }
3789
3790 $site_url = get_site_url();
3791 $sitemaps = array();
3792 $found_index = false;
3793
3794 // Only check the main sitemap index files first (much faster)
3795 // These are the primary entry points that contain sub-sitemaps
3796 $primary_indexes = array(
3797 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
3798 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
3799 'sitemap.xml' => 'Standard', // Generic/AIOSEO
3800 );
3801
3802 foreach ($primary_indexes as $path => $source) {
3803 $url = trailingslashit($site_url) . $path;
3804
3805 $response = wp_remote_head($url, array(
3806 'timeout' => 10,
3807 'sslverify' => false,
3808 'redirection' => 1,
3809 'user-agent' => mxchat_ingest_user_agent(),
3810 ));
3811
3812 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3813 // Found a sitemap index - parse it to get sub-sitemaps
3814 $sub_sitemaps = $this->parse_sitemap_index($url);
3815 if (!empty($sub_sitemaps)) {
3816 $sitemaps[] = array(
3817 'url' => $url,
3818 'type' => 'index',
3819 'source' => $source,
3820 'sub_sitemaps' => $sub_sitemaps
3821 );
3822 $found_index = true;
3823 // Found a valid index, no need to check others
3824 break;
3825 }
3826 }
3827 }
3828
3829 // If no sitemap index found, check for standalone sitemaps
3830 if (!$found_index) {
3831 $standalone_sitemaps = array(
3832 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3833 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3834 );
3835
3836 foreach ($standalone_sitemaps as $path => $info) {
3837 $url = trailingslashit($site_url) . $path;
3838
3839 $response = wp_remote_head($url, array(
3840 'timeout' => 2,
3841 'sslverify' => false
3842 ));
3843
3844 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3845 $sitemaps[] = array(
3846 'url' => $url,
3847 'type' => $info['type'],
3848 'source' => $info['source'],
3849 'url_count' => 0 // Skip URL count for speed
3850 );
3851 }
3852 }
3853 }
3854
3855 wp_send_json_success(array(
3856 'sitemaps' => $sitemaps,
3857 'site_url' => $site_url
3858 ));
3859 }
3860
3861 /**
3862 * Parse a sitemap index to get sub-sitemaps
3863 * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
3864 */
3865 private function parse_sitemap_index($url) {
3866 $sub_sitemaps = array();
3867
3868 $response = wp_remote_get($url, array(
3869 'timeout' => 30,
3870 'sslverify' => false,
3871 'user-agent' => mxchat_ingest_user_agent(),
3872 'headers' => array(
3873 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3874 ),
3875 ));
3876
3877 if (is_wp_error($response)) {
3878 return $sub_sitemaps;
3879 }
3880
3881 $body = wp_remote_retrieve_body($response);
3882 if (empty($body)) {
3883 return $sub_sitemaps;
3884 }
3885
3886 // Suppress XML errors
3887 libxml_use_internal_errors(true);
3888 $xml = simplexml_load_string($body);
3889 libxml_clear_errors();
3890
3891 if ($xml === false) {
3892 return $sub_sitemaps;
3893 }
3894
3895 // Check if it's a sitemap index (contains <sitemap> elements)
3896 if (isset($xml->sitemap)) {
3897 foreach ($xml->sitemap as $sitemap) {
3898 $loc = (string) $sitemap->loc;
3899 if (!empty($loc)) {
3900 // Try to determine the type from the URL
3901 $type = 'content';
3902 if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
3903 $type = 'taxonomy';
3904 } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
3905 $type = 'author';
3906 }
3907
3908 // Skip URL count - too slow to fetch for each sitemap
3909 $sub_sitemaps[] = array(
3910 'url' => $loc,
3911 'type' => $type,
3912 'url_count' => 0, // Don't fetch - takes too long
3913 'name' => basename(parse_url($loc, PHP_URL_PATH))
3914 );
3915 }
3916 }
3917 }
3918
3919 return $sub_sitemaps;
3920 }
3921
3922 /**
3923 * Get URL count from a sitemap
3924 */
3925 private function get_sitemap_url_count($url) {
3926 $response = wp_remote_get($url, array(
3927 'timeout' => 30,
3928 'sslverify' => false,
3929 'user-agent' => mxchat_ingest_user_agent(),
3930 'headers' => array(
3931 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3932 ),
3933 ));
3934
3935 if (is_wp_error($response)) {
3936 return 0;
3937 }
3938
3939 $body = wp_remote_retrieve_body($response);
3940 if (empty($body)) {
3941 return 0;
3942 }
3943
3944 // Count <url> or <loc> elements
3945 $count = preg_match_all('/<url>/i', $body, $matches);
3946 return $count ?: 0;
3947 }
3948
3949 /**
3950 * Get sitemaps declared in robots.txt
3951 */
3952 private function get_sitemaps_from_robots($site_url) {
3953 $sitemaps = array();
3954 $robots_url = trailingslashit($site_url) . 'robots.txt';
3955
3956 $response = wp_remote_get($robots_url, array(
3957 'timeout' => 15,
3958 'sslverify' => false,
3959 'user-agent' => mxchat_ingest_user_agent(),
3960 ));
3961
3962 if (is_wp_error($response)) {
3963 return $sitemaps;
3964 }
3965
3966 $body = wp_remote_retrieve_body($response);
3967 if (empty($body)) {
3968 return $sitemaps;
3969 }
3970
3971 // Find Sitemap: declarations
3972 if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
3973 foreach ($matches[1] as $sitemap_url) {
3974 $sitemap_url = trim($sitemap_url);
3975 if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
3976 $sitemaps[] = $sitemap_url;
3977 }
3978 }
3979 }
3980
3981 return $sitemaps;
3982 }
3983
3984 public function mxchat_stop_processing() {
3985 // Verify permissions
3986 if (!current_user_can('manage_options')) {
3987 wp_die(esc_html__('Unauthorized access', 'mxchat'));
3988 }
3989
3990 // Verify nonce
3991 check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
3992
3993 global $wpdb;
3994 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
3995
3996 // Get active queue IDs
3997 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
3998 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
3999
4000 // Delete all pending items from active queues
4001 if ($sitemap_queue_id) {
4002 $wpdb->delete(
4003 $table_name,
4004 array(
4005 'queue_id' => $sitemap_queue_id,
4006 'status' => 'pending'
4007 ),
4008 array('%s', '%s')
4009 );
4010
4011 delete_transient('mxchat_active_queue_sitemap');
4012 delete_transient('mxchat_last_sitemap_url');
4013 }
4014
4015 if ($pdf_queue_id) {
4016 // Get PDF path before deleting
4017 $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
4018
4019 $wpdb->delete(
4020 $table_name,
4021 array(
4022 'queue_id' => $pdf_queue_id,
4023 'status' => 'pending'
4024 ),
4025 array('%s', '%s')
4026 );
4027
4028 // Delete PDF file
4029 if ($pdf_path && file_exists($pdf_path)) {
4030 wp_delete_file($pdf_path);
4031 }
4032
4033 delete_transient('mxchat_active_queue_pdf');
4034 delete_transient('mxchat_last_pdf_url');
4035 }
4036
4037 // Redirect back with a success message
4038 set_transient('mxchat_admin_notice_success',
4039 esc_html__('Processing has been stopped successfully.', 'mxchat'),
4040 30
4041 );
4042 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
4043 exit;
4044 }
4045
4046 /**
4047 * Get content list for processing
4048 */
4049 public function ajax_mxchat_get_content_list() {
4050 // Verify the nonce
4051 check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
4052
4053 if (!current_user_can('manage_options')) {
4054 wp_send_json_error(__('Unauthorized access', 'mxchat'));
4055 }
4056
4057 $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
4058 $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
4059 $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
4060 $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
4061 $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
4062 $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
4063
4064 // Build query args
4065 $args = array(
4066 'posts_per_page' => $per_page,
4067 'paged' => $page,
4068 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
4069 'orderby' => 'date',
4070 'order' => 'DESC',
4071 );
4072
4073 // Supported media types, and the one-shot WHERE filter that scopes the MIME gate
4074 // to attachment rows when this query spans several post types (plan 01010a).
4075 $attachment_mimes = self::mxchat_supported_attachment_mime_types();
4076 $attachment_where = null;
4077
4078 // Handle post types - IMPROVED VERSION
4079 if ($post_type !== 'all') {
4080 $args['post_type'] = $post_type;
4081
4082 // Media is offered by the dropdown above (attachment is a public post type)
4083 // but could never return a row: attachments are stored with post_status
4084 // 'inherit' and this query asks for publish/draft/pending, so the list was
4085 // empty by construction. Force the status attachments actually use, and
4086 // offer only the file types we can extract text from (plan 01010a).
4087 if ($post_type === 'attachment') {
4088 $args['post_status'] = 'inherit';
4089 $args['post_mime_type'] = $attachment_mimes;
4090 }
4091 } else {
4092 // Get all available post types that might contain content
4093 $all_post_types = array();
4094
4095 // First get all public post types
4096 $public_types = get_post_types(array('public' => true), 'names');
4097 $all_post_types = array_merge($all_post_types, $public_types);
4098
4099 // Add common forum/community post types
4100 $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
4101 foreach ($forum_types as $forum_type) {
4102 if (post_type_exists($forum_type)) {
4103 $all_post_types[] = $forum_type;
4104 }
4105 }
4106
4107 // Add other commonly used post types
4108 $common_types = array('product', 'job_listing', 'event', 'portfolio');
4109 foreach ($common_types as $common_type) {
4110 if (post_type_exists($common_type)) {
4111 $all_post_types[] = $common_type;
4112 }
4113 }
4114
4115 // Remove duplicates and ensure we have at least some post types
4116 $all_post_types = array_unique($all_post_types);
4117
4118 if (empty($all_post_types)) {
4119 // Fallback to basic post types
4120 $all_post_types = array('post', 'page');
4121 }
4122
4123 // Attachments in the "All Content Types" view (plan 01010a). Two things have
4124 // to happen at once and neither can ride $args on its own:
4125 //
4126 // - 'inherit' has to join the status list, because WP_Query takes ONE status
4127 // set for the whole query. Only attachments and revisions use it, and
4128 // revisions are not in $all_post_types, so nothing else widens.
4129 // - the MIME gate CANNOT be $args['post_mime_type'] here — that clause is
4130 // applied to every row, and an ordinary post has an empty post_mime_type,
4131 // so it would empty the entire listing rather than filter the media half.
4132 // A scoped WHERE is the only shape that filters attachments alone.
4133 //
4134 // Media joins this view under the default Published filter and under All
4135 // Statuses; picking Drafts deliberately drops it, because a file has no draft
4136 // state and answering a draft filter with published media is a wrong answer.
4137 $wants_attachments = in_array('attachment', $all_post_types, true)
4138 && ($post_status === 'publish' || $post_status === 'all');
4139
4140 if ($wants_attachments) {
4141 $statuses = array_values(array_unique(array_merge((array) $args['post_status'], array('inherit'))));
4142 $args['post_status'] = $statuses;
4143
4144 global $wpdb;
4145 $mime_placeholders = implode(', ', array_fill(0, count($attachment_mimes), '%s'));
4146 $mime_clause = $wpdb->prepare(
4147 " AND ({$wpdb->posts}.post_type <> 'attachment' OR {$wpdb->posts}.post_mime_type IN ($mime_placeholders))",
4148 $attachment_mimes
4149 );
4150 $attachment_where = function ($where) use ($mime_clause) {
4151 return $where . $mime_clause;
4152 };
4153 add_filter('posts_where', $attachment_where);
4154 } else {
4155 $all_post_types = array_values(array_diff($all_post_types, array('attachment')));
4156 }
4157
4158 $args['post_type'] = $all_post_types;
4159
4160 // Debug logging to see what post types are being queried
4161 //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
4162 }
4163
4164 if (!empty($search)) {
4165 $args['s'] = $search;
4166 }
4167
4168 // Get processed data from storage
4169 $processed_data = array();
4170
4171 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4172 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4173
4174 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4175 // Get fresh data from Pinecone - no caching
4176 $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
4177 } else {
4178 // WordPress DB checking with better URL matching for all post types
4179 global $wpdb;
4180 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4181 $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
4182
4183 // Group items by source_url to count chunks
4184 $url_chunk_counts = array();
4185 $url_latest_timestamp = array();
4186 $url_first_id = array();
4187
4188 if (!empty($processed_items)) {
4189 foreach ($processed_items as $item) {
4190 $url = $item->source_url;
4191 if (empty($url)) continue;
4192
4193 // Count chunks per URL
4194 if (!isset($url_chunk_counts[$url])) {
4195 $url_chunk_counts[$url] = 0;
4196 $url_latest_timestamp[$url] = $item->timestamp;
4197 $url_first_id[$url] = $item->id;
4198 }
4199 $url_chunk_counts[$url]++;
4200
4201 // Track latest timestamp
4202 if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
4203 $url_latest_timestamp[$url] = $item->timestamp;
4204 }
4205 }
4206
4207 // Now build processed_data with chunk counts
4208 foreach ($url_chunk_counts as $url => $chunk_count) {
4209 $post_id = $this->mxchat_url_to_post_id_improved($url);
4210
4211 if ($post_id) {
4212 $processed_data[$post_id] = array(
4213 'db_id' => $url_first_id[$url],
4214 'timestamp' => $url_latest_timestamp[$url],
4215 'url' => $url,
4216 'source' => 'wordpress',
4217 'chunk_count' => $chunk_count
4218 );
4219 }
4220 }
4221 }
4222 }
4223
4224 // Get processed IDs as a simple array for in_array checks
4225 $processed_ids = array_keys($processed_data);
4226
4227 // Handle processed/unprocessed filter
4228 if ($processed_filter === 'processed' && !empty($processed_ids)) {
4229 $args['post__in'] = $processed_ids;
4230 } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
4231 $args['post__not_in'] = $processed_ids;
4232 }
4233
4234 // Run the query
4235 $query = new WP_Query($args);
4236
4237 // Scoped to this one query — leaving it attached would MIME-filter every later
4238 // WP_Query in the request.
4239 if ($attachment_where) {
4240 remove_filter('posts_where', $attachment_where);
4241 }
4242
4243 $content_items = array();
4244
4245 if ($query->have_posts()) {
4246 while ($query->have_posts()) {
4247 $query->the_post();
4248 $id = get_the_ID();
4249 $post_date = get_the_date();
4250 $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
4251 $word_count = str_word_count(strip_tags(get_the_content()));
4252
4253 $is_processed = in_array($id, $processed_ids);
4254 $processed_date = '';
4255 $db_record_id = 0;
4256 $data_source = 'none';
4257
4258 if ($is_processed && isset($processed_data[$id])) {
4259 $item_data = $processed_data[$id];
4260 $data_source = $item_data['source'];
4261
4262 if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
4263 // WordPress DB format
4264 $timestamp = strtotime($item_data['timestamp']);
4265 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4266 $db_record_id = $item_data['db_id'];
4267 } elseif ($data_source === 'pinecone') {
4268 // Pinecone format
4269 $processed_date = $item_data['processed_date'];
4270 $db_record_id = $item_data['db_id'];
4271 }
4272 }
4273
4274 // Get chunk count for this item
4275 $chunk_count = 0;
4276 if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
4277 $chunk_count = intval($processed_data[$id]['chunk_count']);
4278 }
4279
4280 // Media rows describe themselves differently (plan 01010a): the link has
4281 // to be the FILE, not the attachment page nobody wants; "attachment" and
4282 // "0 words" tell the user nothing, so the row carries the file type and
4283 // its size instead. Word count is deliberately NOT computed — that would
4284 // mean parsing every PDF in the library just to render a list.
4285 $permalink = get_permalink();
4286 $type_label = get_post_type();
4287 $size_label = '';
4288
4289 if (get_post_type() === 'attachment') {
4290 $file_url = wp_get_attachment_url($id);
4291 $permalink = $file_url ? $file_url : $permalink;
4292 $type_label = self::mxchat_attachment_type_label(get_post_mime_type($id));
4293
4294 $file_path = get_attached_file($id);
4295 $file_size = ($file_path && file_exists($file_path)) ? @filesize($file_path) : false;
4296 if ($file_size !== false) {
4297 $size_label = size_format($file_size, 0);
4298 }
4299
4300 if ($excerpt === '') {
4301 $excerpt = $file_url ? wp_basename($file_url) : '';
4302 }
4303 }
4304
4305 $content_items[] = array(
4306 'id' => $id,
4307 'title' => get_the_title(),
4308 'permalink' => $permalink,
4309 'date' => $post_date,
4310 'type' => get_post_type(),
4311 'type_label' => $type_label,
4312 'size_label' => $size_label,
4313 'status' => get_post_status(),
4314 'excerpt' => $excerpt,
4315 'word_count' => $word_count,
4316 'already_processed' => $is_processed,
4317 'processed_date' => $processed_date,
4318 'db_record_id' => $db_record_id,
4319 'data_source' => $data_source,
4320 'chunk_count' => $chunk_count
4321 );
4322 }
4323 wp_reset_postdata();
4324 }
4325
4326 $response = array(
4327 'items' => $content_items,
4328 'total' => $query->found_posts,
4329 'total_pages' => $query->max_num_pages,
4330 'current_page' => $page,
4331 'processed_count' => count($processed_ids)
4332 );
4333
4334 wp_send_json_success($response);
4335 exit;
4336 }
4337
4338
4339 /**
4340 * This function handles various WooCommerce URL formats and permalink structures
4341 */
4342 private function mxchat_url_to_post_id_improved($url) {
4343 // First try the standard WordPress function
4344 $post_id = url_to_postid($url);
4345
4346 if ($post_id > 0) {
4347 return $post_id;
4348 }
4349
4350 // Media entries are stored under the FILE url (…/wp-content/uploads/…), which is
4351 // not a permalink — url_to_postid() never resolves one, and every slug fallback
4352 // below excludes 'attachment' explicitly. Without this, an imported media file
4353 // renders as "Not In Knowledge Base" forever and re-imports as new every time
4354 // (plan 01010a). attachment_url_to_postid() matches on _wp_attached_file and
4355 // returns 0 for anything outside the uploads directory, so post permalinks are
4356 // unaffected.
4357 $attachment_id = (int) attachment_url_to_postid($url);
4358 if ($attachment_id > 0) {
4359 return $attachment_id;
4360 }
4361
4362 // If that fails, try more aggressive URL matching
4363 // Remove trailing slashes and query parameters for better matching
4364 $clean_url = rtrim($url, '/');
4365 $clean_url = strtok($clean_url, '?'); // Remove query parameters
4366
4367 // Try again with cleaned URL
4368 $post_id = url_to_postid($clean_url);
4369 if ($post_id > 0) {
4370 return $post_id;
4371 }
4372
4373 // For bbPress forum topics, try extracting slug from URL
4374 if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
4375 // Handle bbPress URLs: /forums/topic/topic-name/
4376 if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
4377 $topic_slug = $matches[1];
4378
4379 // Look up topic by slug
4380 $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
4381 if ($topic) {
4382 return $topic->ID;
4383 }
4384
4385 // Alternative method: query by post_name
4386 global $wpdb;
4387 $post_id = $wpdb->get_var($wpdb->prepare(
4388 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4389 $topic_slug
4390 ));
4391
4392 if ($post_id) {
4393 return intval($post_id);
4394 }
4395 }
4396
4397 // Handle simpler topic URLs: /topic/topic-name/
4398 if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
4399 $topic_slug = $matches[1];
4400
4401 global $wpdb;
4402 $post_id = $wpdb->get_var($wpdb->prepare(
4403 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4404 $topic_slug
4405 ));
4406
4407 if ($post_id) {
4408 return intval($post_id);
4409 }
4410 }
4411 }
4412
4413 // For WooCommerce products
4414 if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
4415 // Extract product slug from various URL formats
4416 $product_slug = '';
4417
4418 // Handle pretty permalinks: /product/product-name/
4419 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
4420 $product_slug = $matches[1];
4421 }
4422 // Handle query parameters: ?product=product-name
4423 elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
4424 $product_slug = $matches[1];
4425 }
4426
4427 if (!empty($product_slug)) {
4428 // Look up product by slug
4429 $product = get_page_by_path($product_slug, OBJECT, 'product');
4430 if ($product) {
4431 return $product->ID;
4432 }
4433
4434 // Alternative method: query by post_name
4435 global $wpdb;
4436 $post_id = $wpdb->get_var($wpdb->prepare(
4437 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
4438 $product_slug
4439 ));
4440
4441 if ($post_id) {
4442 return intval($post_id);
4443 }
4444 }
4445 }
4446
4447 // Generic approach: try to extract slug and match against all post types
4448 $parsed_url = wp_parse_url($clean_url);
4449 $path = $parsed_url['path'] ?? '';
4450
4451 if (!empty($path)) {
4452 // Get the last part of the path as potential slug
4453 $path_parts = array_filter(explode('/', trim($path, '/')));
4454 $potential_slug = end($path_parts);
4455
4456 if (!empty($potential_slug)) {
4457 global $wpdb;
4458
4459 // Try to find any post with this slug
4460 $post_id = $wpdb->get_var($wpdb->prepare(
4461 "SELECT ID FROM {$wpdb->posts}
4462 WHERE post_name = %s
4463 AND post_status IN ('publish', 'closed', 'private')
4464 AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
4465 ORDER BY CASE
4466 WHEN post_type = 'post' THEN 1
4467 WHEN post_type = 'page' THEN 2
4468 WHEN post_type = 'topic' THEN 3
4469 WHEN post_type = 'product' THEN 4
4470 ELSE 5
4471 END
4472 LIMIT 1",
4473 $potential_slug
4474 ));
4475
4476 if ($post_id) {
4477 return intval($post_id);
4478 }
4479 }
4480 }
4481
4482 // ADDITIONAL: Try direct database lookup by URL variations
4483 global $wpdb;
4484 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4485
4486 // Try variations of the URL (with/without trailing slash, http/https)
4487 $url_variations = array(
4488 $url,
4489 rtrim($url, '/'),
4490 $url . '/',
4491 str_replace('http://', 'https://', $url),
4492 str_replace('https://', 'http://', $url),
4493 str_replace('http://', 'https://', rtrim($url, '/')),
4494 str_replace('https://', 'http://', rtrim($url, '/'))
4495 );
4496
4497 // Remove duplicates
4498 $url_variations = array_unique($url_variations);
4499
4500 foreach ($url_variations as $variation) {
4501 $existing_record = $wpdb->get_row($wpdb->prepare(
4502 "SELECT id, source_url FROM $table_name WHERE source_url = %s",
4503 $variation
4504 ));
4505
4506 if ($existing_record) {
4507 // Try to get post ID from this stored URL
4508 $stored_post_id = url_to_postid($existing_record->source_url);
4509 if ($stored_post_id > 0) {
4510 return $stored_post_id;
4511 }
4512 }
4513 }
4514
4515 return 0; // No match found
4516 }
4517 /**
4518 * Process selected content via AJAX
4519 */
4520 public function ajax_mxchat_process_selected_content() {
4521 // Basic request validation
4522 if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
4523 wp_send_json_error('Invalid nonce');
4524 exit;
4525 }
4526
4527 if (!current_user_can('manage_options')) {
4528 wp_send_json_error('Unauthorized access');
4529 exit;
4530 }
4531
4532 // Get post IDs - safely parse the array
4533 $post_ids = array();
4534 if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
4535 foreach ($_POST['post_ids'] as $id) {
4536 $post_ids[] = absint($id);
4537 }
4538 }
4539
4540 if (empty($post_ids)) {
4541 wp_send_json_error('No content selected');
4542 exit;
4543 }
4544
4545 // Get bot_id from request
4546 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
4547
4548 // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields,
4549 // plan 11720c). The import modal shows a passive status line pointing
4550 // there; the old per-batch checkbox and its remembered default are gone.
4551 $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1';
4552
4553 // Process only ONE post at a time to avoid request size issues
4554 $post_id = reset($post_ids);
4555 $post = get_post($post_id);
4556
4557 if (!$post) {
4558 wp_send_json_error('Post not found');
4559 exit;
4560 }
4561
4562 // Media-library attachments take their own route (plan 01010a) — different source
4563 // URL (the file, not the attachment page), different body (an extractor, not
4564 // post_content), and a file that yields no text is refused with a reason instead
4565 // of stored empty. Sends its own response and exits.
4566 if ($post->post_type === 'attachment') {
4567 $this->mxchat_import_attachment($post, $bot_id);
4568 }
4569
4570 /**
4571 * Allow developers to modify post data before processing into the knowledge base.
4572 * Applied on BOTH content-preparation paths (this manual bulk import and the
4573 * auto-sync path in mxchat_handle_post_update) with the same signature, so a
4574 * callback registered once covers every indexing route. Purely additive —
4575 * zero behaviour change when unhooked.
4576 *
4577 * @param WP_Post $post The post about to be indexed.
4578 * @param string $bot_id Bot context for this import.
4579 */
4580 $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
4581 if (!($post instanceof WP_Post)) {
4582 $post = get_post($post_id); // defend against a bad callback return
4583 }
4584
4585 // Assemble the indexable text via the shared post-kind assembler (a3d60c).
4586 // Bulk import reads raw post fields, has always included product custom tabs,
4587 // and passes the install-level ACF→PDF option.
4588 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
4589 'read_display' => false,
4590 'extract_acf_pdfs' => $extract_acf_pdfs,
4591 'include_product_tabs' => true,
4592 ));
4593 $content = $prepared['content'];
4594 $pdf_extracted_count = $prepared['pdf_extracted_count'];
4595
4596 // Debug logging for WordPress Import content
4597 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
4598 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
4599 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
4600 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
4601
4602 // Note: Removed 10,000 char limit - chunking now handles large content properly
4603
4604 // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd)
4605 $bot_options = $this->get_bot_options($bot_id);
4606 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4607
4608 $preflight = MxChat_Utils::embedding_preflight($options);
4609 if (!$preflight['ok']) {
4610 MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)');
4611 wp_send_json_error($preflight['reason']);
4612 exit;
4613 }
4614 $api_key = $preflight['api_key'];
4615
4616 $source_url = get_permalink($post_id);
4617 $vector_id = md5($source_url); // Vector ID for Pinecone
4618
4619 // Check for existing content in bot-specific storage
4620 $is_update = false;
4621
4622 // Get bot-specific Pinecone configuration
4623 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4624 $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
4625
4626 if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
4627 // Check Pinecone for this bot
4628 $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
4629 if (isset($pinecone_data[$post_id])) {
4630 $is_update = true;
4631 }
4632 } else {
4633 // Check WordPress DB (same as before since it's shared)
4634 global $wpdb;
4635 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4636 $existing_record = $wpdb->get_row($wpdb->prepare(
4637 "SELECT id FROM $table_name WHERE source_url = %s",
4638 $source_url
4639 ));
4640
4641 if ($existing_record) {
4642 $is_update = true;
4643 }
4644 }
4645
4646 // UPDATED 2.5.6: Determine content type based on post_type
4647 $post_type = $post->post_type;
4648 $content_type = 'content'; // Default fallback
4649
4650 // Map WordPress post types to content types
4651 switch ($post_type) {
4652 case 'post':
4653 $content_type = 'post';
4654 break;
4655 case 'page':
4656 $content_type = 'page';
4657 break;
4658 case 'product':
4659 $content_type = 'product';
4660 break;
4661 default:
4662 // For custom post types, use the post type name
4663 $content_type = sanitize_key($post_type);
4664 break;
4665 }
4666
4667 // Use the centralized utility function with bot_id and content_type
4668 $result = MxChat_Utils::submit_content_to_db(
4669 $content,
4670 $source_url,
4671 $api_key,
4672 $vector_id,
4673 $bot_id,
4674 $content_type
4675 );
4676
4677 if (is_wp_error($result)) {
4678 MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
4679 wp_send_json_error('Storage failed: ' . $result->get_error_message());
4680 exit;
4681 }
4682
4683 // Automatically apply role restriction based on tags
4684 $this->apply_role_restriction_to_post($post_id, $source_url);
4685
4686 $operation_type = $is_update ? 'update' : 'new';
4687
4688 // Count ACF fields for debugging
4689 $acf_field_count = $prepared['acf_fields_found'];
4690
4691 // Success response with minimal data
4692 wp_send_json_success(array(
4693 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
4694 'post_id' => $post_id,
4695 'title' => $post->post_title,
4696 'operation_type' => $operation_type,
4697 'vector_id' => $vector_id,
4698 'acf_fields_found' => $acf_field_count,
4699 'pdf_extracted_count' => (int) $pdf_extracted_count,
4700 'content_preview' => substr($content, 0, 100) . '...',
4701 'bot_id' => $bot_id
4702 ));
4703 exit;
4704 }
4705
4706 private function apply_role_restriction_to_post($post_id, $source_url) {
4707 $highest_role = 'public';
4708
4709 // Tag-derived restriction. Tags only exist on posts, which is why pages
4710 // could never be restricted from the Role Restrictions screen (plan 6ab002).
4711 $mappings = get_option('mxchat_tag_role_mappings', array());
4712 if (!empty($mappings)) {
4713 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
4714 if (!empty($post_tags)) {
4715 foreach ($post_tags as $tag_slug) {
4716 if (isset($mappings[$tag_slug])) {
4717 $highest_role = self::mxchat_stricter_role($highest_role, $mappings[$tag_slug]);
4718 }
4719 }
4720 }
4721 }
4722
4723 // Per-entry restriction set on the post itself, which works for pages and
4724 // every other public post type. The stricter of the two wins.
4725 $meta_role = get_post_meta($post_id, '_mxchat_kb_role_restriction', true);
4726 if (is_string($meta_role) && $meta_role !== '') {
4727 $highest_role = self::mxchat_stricter_role($highest_role, $meta_role);
4728 }
4729
4730 // Nothing restricts this entry — leave it public.
4731 if ($highest_role === 'public' || $highest_role === '') {
4732 return;
4733 }
4734
4735 // Update the role restriction in the database
4736 global $wpdb;
4737
4738 // Check if using Pinecone
4739 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4740 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4741
4742 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4743 // Update Pinecone role restriction
4744 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4745 $vector_id = md5($source_url);
4746
4747 $wpdb->replace(
4748 $roles_table,
4749 array(
4750 'vector_id' => $vector_id,
4751 'role_restriction' => $highest_role,
4752 'updated_at' => current_time('mysql')
4753 ),
4754 array('%s', '%s', '%s')
4755 );
4756 } else {
4757 // Update WordPress DB
4758 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4759
4760 $wpdb->update(
4761 $table_name,
4762 array('role_restriction' => $highest_role),
4763 array('source_url' => $source_url),
4764 array('%s'),
4765 array('%s')
4766 );
4767 }
4768
4769 // The entry's restriction just changed — keep the OpenAI Vector Store
4770 // mirror consistent: non-public pulls the file (file_search has no
4771 // per-role filtering), public re-mirrors it (plan 15b5c6).
4772 if (class_exists('MxChat_Vectorstore_Manager')) {
4773 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
4774 }
4775 }
4776
4777 public function mxchat_get_public_post_types() {
4778 // Get all public post types
4779 $post_types = get_post_types(array('public' => true), 'objects');
4780 $post_type_options = array();
4781
4782 foreach ($post_types as $post_type) {
4783 $post_type_options[$post_type->name] = $post_type->label;
4784 }
4785
4786 // Also include common forum/community post types that might not be marked as public
4787 $additional_types = array(
4788 'topic' => 'Forum Topics (bbPress)',
4789 'reply' => 'Forum Replies (bbPress)',
4790 'forum' => 'Forums (bbPress)',
4791 'wpforo_topic' => 'wpForo Topics',
4792 'wpforo_post' => 'wpForo Posts'
4793 );
4794
4795 foreach ($additional_types as $type_name => $type_label) {
4796 if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
4797 $post_type_options[$type_name] = $type_label;
4798 }
4799 }
4800
4801 return $post_type_options;
4802 }
4803
4804 /**
4805 * Retrieves processed content from Pinecone API
4806 */
4807 public function mxchat_get_pinecone_processed_content($pinecone_options) {
4808 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4809 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4810
4811 if (empty($api_key) || empty($host)) {
4812 return array();
4813 }
4814
4815 $pinecone_data = array();
4816
4817 try {
4818 // Always get fresh data from Pinecone
4819 $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
4820
4821 // Method 2: Final fallback - try stats endpoint (if available)
4822 if (empty($pinecone_data)) {
4823 $stats_url = "https://{$host}/describe_index_stats";
4824
4825 $response = wp_remote_post($stats_url, array(
4826 'headers' => array(
4827 'Api-Key' => $api_key,
4828 'Content-Type' => 'application/json'
4829 ),
4830 'body' => json_encode(array()),
4831 'timeout' => 30
4832 ));
4833
4834 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4835 $body = wp_remote_retrieve_body($response);
4836 $stats_data = json_decode($body, true);
4837 }
4838 }
4839
4840 } catch (Exception $e) {
4841 // Log error but return fresh data only
4842 }
4843
4844 return $pinecone_data;
4845 }
4846 public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
4847 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4848 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4849 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4850
4851 if (empty($api_key) || empty($host) || empty($vector_ids)) {
4852 return array();
4853 }
4854
4855 try {
4856 // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
4857 // repeated in the query string (ids=a&ids=b — http_build_query would
4858 // emit ids[0]=a); the old POST here was answered 200-with-an-empty-body,
4859 // which read as "nothing indexed". Chunked at 100 ids to stay well
4860 // under the measured HTTP 414 URL-length boundary.
4861 $vectors = array();
4862 // Document index (plan 362c31): documents/fetch instead of the GET loop (same id => vector shape).
4863 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
4864 $vectors = MxChat_Pinecone_Documents::fetch(array_values($vector_ids), MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
4865 } else
4866 foreach (array_chunk(array_values($vector_ids), 100) as $chunk) {
4867 $fetch_query = array();
4868 foreach ($chunk as $fetch_vid) {
4869 $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
4870 }
4871 if (!empty($namespace)) {
4872 $fetch_query[] = 'namespace=' . rawurlencode($namespace);
4873 }
4874
4875 $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
4876 'headers' => array(
4877 'Api-Key' => $api_key,
4878 'accept' => 'application/json'
4879 ),
4880 'timeout' => 30
4881 ));
4882
4883 if (is_wp_error($response)) {
4884 error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET failed: ' . $response->get_error_message());
4885 continue;
4886 }
4887
4888 if (wp_remote_retrieve_response_code($response) !== 200) {
4889 error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET returned HTTP ' . wp_remote_retrieve_response_code($response));
4890 continue;
4891 }
4892
4893 $data = json_decode(wp_remote_retrieve_body($response), true);
4894 if (isset($data['vectors']) && is_array($data['vectors'])) {
4895 $vectors += $data['vectors'];
4896 }
4897 }
4898
4899 if (empty($vectors)) {
4900 return array();
4901 }
4902
4903 $processed_data = array();
4904
4905 foreach ($vectors as $vector_id => $vector_data) {
4906 $metadata = $vector_data['metadata'] ?? array();
4907 $source_url = $metadata['source_url'] ?? '';
4908
4909 if (!empty($source_url)) {
4910 $post_id = url_to_postid($source_url);
4911 // A media entry is stored under its FILE url, which url_to_postid() cannot
4912 // resolve — without this the Pinecone read reports every imported
4913 // attachment as unprocessed while the WordPress-DB read (which goes
4914 // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
4915 if (!$post_id) {
4916 $post_id = (int) attachment_url_to_postid($source_url);
4917 }
4918 if ($post_id) {
4919 $created_at = $metadata['created_at'] ?? '';
4920 $processed_date = 'Recently';
4921
4922 if (!empty($created_at)) {
4923 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4924 if ($timestamp) {
4925 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4926 }
4927 }
4928
4929 $processed_data[$post_id] = array(
4930 'db_id' => $vector_id,
4931 'processed_date' => $processed_date,
4932 'url' => $source_url,
4933 'source' => 'pinecone',
4934 'timestamp' => $timestamp ?? current_time('timestamp')
4935 );
4936 }
4937 }
4938 }
4939
4940 //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
4941 return $processed_data;
4942
4943 } catch (Exception $e) {
4944 //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
4945 return array();
4946 }
4947 }
4948
4949 /**
4950 * Get embedding dimensions based on the selected model.
4951 */
4952 private function mxchat_get_embedding_dimensions() {
4953 $options = get_option('mxchat_options', array());
4954 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4955
4956 $model_dimensions = array(
4957 'text-embedding-ada-002' => 1536,
4958 'text-embedding-3-small' => 1536,
4959 'text-embedding-3-large' => 3072,
4960 'voyage-2' => 1024,
4961 'voyage-large-2' => 1536,
4962 'voyage-3-large' => 2048,
4963 'gemini-embedding-001' => 1536,
4964 );
4965
4966 if (strpos($selected_model, 'voyage-3-large') === 0) {
4967 $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
4968 return intval($custom_dimensions);
4969 }
4970
4971 if (strpos($selected_model, 'gemini-embedding') === 0) {
4972 $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
4973 return intval($custom_dimensions);
4974 }
4975
4976 return $model_dimensions[$selected_model] ?? 1536;
4977 }
4978
4979 /**
4980 * Scan Pinecone for processed content
4981 */
4982 public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4983 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4984 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4985 // plan 793b82: this scan was namespace-blind — on a namespaced setup it
4986 // surveyed the default namespace and reported the wrong content as indexed.
4987 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4988
4989 if (empty($api_key) || empty($host)) {
4990 return array();
4991 }
4992
4993 try {
4994 // Use multiple random vectors to get better coverage
4995 $all_matches = array();
4996 $seen_ids = array();
4997
4998 // Get correct dimensions for the configured embedding model
4999 $dimensions = $this->mxchat_get_embedding_dimensions();
5000
5001 // Try 3 different random vectors to get better coverage
5002 for ($i = 0; $i < 3; $i++) {
5003 $query_url = "https://{$host}/query";
5004
5005 // Generate a random unit vector instead of zeros
5006 $random_vector = array();
5007 for ($j = 0; $j < $dimensions; $j++) {
5008 $random_vector[] = (rand(-1000, 1000) / 1000.0);
5009 }
5010
5011 // Normalize the vector to unit length
5012 $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
5013 if ($magnitude > 0) {
5014 $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
5015 }
5016
5017 $query_data = array(
5018 'includeMetadata' => true,
5019 'includeValues' => false,
5020 'topK' => 10000,
5021 'vector' => $random_vector
5022 );
5023
5024 if (!empty($namespace)) {
5025 $query_data['namespace'] = $namespace;
5026 }
5027
5028 // Document index (plan 362c31): same random-vector sweep through documents/search.
5029 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
5030 $doc_hits = MxChat_Pinecone_Documents::search_dense($random_vector, 10000, null, MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
5031 foreach ((is_wp_error($doc_hits) ? array() : $doc_hits) as $match) {
5032 $match_id = $match['id'] ?? '';
5033 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5034 $all_matches[] = $match;
5035 $seen_ids[$match_id] = true;
5036 }
5037 }
5038 continue;
5039 }
5040
5041 $response = wp_remote_post($query_url, array(
5042 'headers' => array(
5043 'Api-Key' => $api_key,
5044 'Content-Type' => 'application/json'
5045 ),
5046 'body' => json_encode($query_data),
5047 'timeout' => 30
5048 ));
5049
5050 if (is_wp_error($response)) {
5051 continue;
5052 }
5053
5054 $response_code = wp_remote_retrieve_response_code($response);
5055
5056 if ($response_code !== 200) {
5057 continue;
5058 }
5059
5060 $body = wp_remote_retrieve_body($response);
5061 $data = json_decode($body, true);
5062
5063 if (isset($data['matches'])) {
5064 foreach ($data['matches'] as $match) {
5065 $match_id = $match['id'] ?? '';
5066 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5067 $all_matches[] = $match;
5068 $seen_ids[$match_id] = true;
5069 }
5070 }
5071 }
5072 }
5073
5074 // Convert matches to processed data format, grouping by URL to count chunks
5075 $processed_data = array();
5076 $url_chunk_counts = array();
5077
5078 foreach ($all_matches as $match) {
5079 $metadata = $match['metadata'] ?? array();
5080 $source_url = $metadata['source_url'] ?? '';
5081 $match_id = $match['id'] ?? '';
5082
5083 if (!empty($source_url) && !empty($match_id)) {
5084 $post_id = url_to_postid($source_url);
5085 // A media entry is stored under its FILE url, which url_to_postid() cannot
5086 // resolve — without this the Pinecone read reports every imported
5087 // attachment as unprocessed while the WordPress-DB read (which goes
5088 // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
5089 if (!$post_id) {
5090 $post_id = (int) attachment_url_to_postid($source_url);
5091 }
5092 if ($post_id) {
5093 // Count chunks per post_id
5094 if (!isset($url_chunk_counts[$post_id])) {
5095 $url_chunk_counts[$post_id] = 0;
5096 }
5097 $url_chunk_counts[$post_id]++;
5098
5099 $created_at = $metadata['created_at'] ?? '';
5100 $processed_date = 'Recently';
5101
5102 if (!empty($created_at)) {
5103 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
5104 if ($timestamp) {
5105 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
5106 }
5107 }
5108
5109 // Only store if not already set, or update with newer timestamp
5110 if (!isset($processed_data[$post_id]) ||
5111 ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
5112 $processed_data[$post_id] = array(
5113 'db_id' => $match_id,
5114 'processed_date' => $processed_date,
5115 'url' => $source_url,
5116 'source' => 'pinecone',
5117 'timestamp' => $timestamp ?? current_time('timestamp')
5118 );
5119 }
5120 }
5121 }
5122 }
5123
5124 // Add chunk counts to processed data
5125 foreach ($url_chunk_counts as $post_id => $chunk_count) {
5126 if (isset($processed_data[$post_id])) {
5127 $processed_data[$post_id]['chunk_count'] = $chunk_count;
5128 }
5129 }
5130
5131 return $processed_data;
5132
5133 } catch (Exception $e) {
5134 return array();
5135 }
5136 }
5137 /**
5138 * Generate embeddings from input text for MXChat with bot support
5139 */
5140 private function mxchat_generate_embedding($text, $bot_id = 'default') {
5141 // Enable detailed logging for debugging
5142 //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
5143 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
5144
5145 // Get bot-specific options
5146 $bot_options = $this->get_bot_options($bot_id);
5147 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
5148
5149 // Opt-in: when the custom provider is selected for embeddings, index through
5150 // the same custom endpoint the query path uses so stored vectors and query
5151 // vectors share a model. Returns the vector array on success, or an error
5152 // string on failure (this function's existing failure contract).
5153 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5154 if (!class_exists('MxChat_Utils')) {
5155 require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
5156 }
5157 return MxChat_Utils::generate_embedding_custom($text, $options);
5158 }
5159
5160 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5161 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
5162
5163 // Determine provider and endpoint
5164 if (strpos($selected_model, 'voyage') === 0) {
5165 $api_key = $options['voyage_api_key'] ?? '';
5166 $endpoint = 'https://api.voyageai.com/v1/embeddings';
5167 $provider_name = 'Voyage AI';
5168 //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
5169 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5170 $api_key = $options['gemini_api_key'] ?? '';
5171 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
5172 $provider_name = 'Google Gemini';
5173 //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
5174 } else {
5175 $api_key = $options['api_key'] ?? '';
5176 $endpoint = 'https://api.openai.com/v1/embeddings';
5177 $provider_name = 'OpenAI';
5178 //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
5179 }
5180
5181 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
5182
5183 if (empty($api_key)) {
5184 $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
5185 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
5186 return $error_message;
5187 }
5188
5189 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
5190 $estimated_tokens = ceil(str_word_count($text) / 0.75);
5191 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
5192
5193 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
5194 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
5195 // Consider truncating text here
5196 }
5197
5198 // Prepare request body based on provider
5199 if (strpos($selected_model, 'gemini-embedding') === 0) {
5200 // Gemini API format
5201 $request_body = array(
5202 'model' => 'models/' . $selected_model,
5203 'content' => array(
5204 'parts' => array(
5205 array('text' => $text)
5206 )
5207 )
5208 );
5209
5210 // Set output dimensionality to 1536 for consistency with other models
5211 $request_body['outputDimensionality'] = 1536;
5212 } else {
5213 // OpenAI/Voyage API format
5214 $request_body = array(
5215 'model' => $selected_model,
5216 'input' => $text
5217 );
5218
5219 // Add output_dimension for voyage-3-large model
5220 if ($selected_model === 'voyage-3-large') {
5221 $request_body['output_dimension'] = 2048;
5222 }
5223 }
5224
5225 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
5226
5227 // Prepare headers based on provider
5228 if (strpos($selected_model, 'gemini-embedding') === 0) {
5229 // Gemini uses API key as query parameter
5230 $endpoint .= '?key=' . $api_key;
5231 $headers = array(
5232 'Content-Type' => 'application/json'
5233 );
5234 } else {
5235 // OpenAI/Voyage use Bearer token
5236 $headers = array(
5237 'Authorization' => 'Bearer ' . $api_key,
5238 'Content-Type' => 'application/json'
5239 );
5240 }
5241
5242 // Make API request
5243 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
5244 $response = wp_remote_post($endpoint, array(
5245 'body' => wp_json_encode($request_body),
5246 'headers' => $headers,
5247 'timeout' => 60 // Increased timeout for large inputs
5248 ));
5249
5250 // Handle wp_remote_post errors
5251 if (is_wp_error($response)) {
5252 $error_message = $response->get_error_message();
5253 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
5254 return 'Connection error: ' . $error_message;
5255 }
5256
5257 // Get and check HTTP response code
5258 $http_code = wp_remote_retrieve_response_code($response);
5259 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
5260
5261 if ($http_code !== 200) {
5262 $error_body = wp_remote_retrieve_body($response);
5263 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
5264
5265 // Try to parse error for more details
5266 $error_json = json_decode($error_body, true);
5267 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
5268 $error_type = $error_json['error']['type'] ?? 'unknown';
5269 $error_message = $error_json['error']['message'] ?? 'No message';
5270 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
5271 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
5272
5273 // Keep the provider's own diagnostic — a restricted-key 401 names the
5274 // exact missing scope, and replacing it with "check your API key" sent
5275 // a customer to regenerate two keys (plan 46b596). Same shape as
5276 // MxChat_Utils::embedding_failure_error() so both ingestion paths read
5277 // identically. Key never appears in provider messages, but scrub anyway.
5278 if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') {
5279 if (is_string($api_key) && $api_key !== '') {
5280 $error_message = str_replace($api_key, '[redacted]', $error_message);
5281 }
5282 $error_message = sprintf(
5283 'Embedding failed (%s, HTTP %d): %s',
5284 $selected_model,
5285 $http_code,
5286 substr($error_message, 0, 300)
5287 );
5288 }
5289
5290 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5291 return $error_message;
5292 }
5293
5294 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
5295 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5296 return $error_message;
5297 }
5298
5299 // Parse response body
5300 $response_body = wp_remote_retrieve_body($response);
5301 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
5302
5303 $response_data = json_decode($response_body, true);
5304
5305 if (json_last_error() !== JSON_ERROR_NONE) {
5306 $error = json_last_error_msg();
5307 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
5308 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
5309 return "Failed to parse API response: $error";
5310 }
5311
5312 // Handle different response formats based on provider
5313 if (strpos($selected_model, 'gemini-embedding') === 0) {
5314 // Gemini API response format
5315 if (isset($response_data['embedding']['values'])) {
5316 $embedding_dimensions = count($response_data['embedding']['values']);
5317 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
5318
5319 // Check if embedding dimensions are as expected (should be 1536)
5320 if ($embedding_dimensions !== 1536) {
5321 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
5322 }
5323
5324 return $response_data['embedding']['values'];
5325 } else {
5326 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
5327 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5328
5329 if (isset($response_data['error'])) {
5330 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
5331 //error_log('[MXCHAT-EMBED] ' . $error_message);
5332 return $error_message;
5333 }
5334
5335 $error_message = "Invalid Gemini API response format: No embedding found";
5336 //error_log('[MXCHAT-EMBED] ' . $error_message);
5337 return $error_message;
5338 }
5339 } else {
5340 // OpenAI/Voyage API response format
5341 if (isset($response_data['data'][0]['embedding'])) {
5342 $embedding_dimensions = count($response_data['data'][0]['embedding']);
5343 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
5344
5345 // Check if embedding dimensions are as expected
5346 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
5347 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
5348 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
5349 }
5350
5351 return $response_data['data'][0]['embedding'];
5352 } else {
5353 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
5354 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5355
5356 if (isset($response_data['error'])) {
5357 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
5358 //error_log('[MXCHAT-EMBED] ' . $error_message);
5359 return $error_message;
5360 }
5361
5362 $error_message = "Invalid API response format: No embedding found";
5363 //error_log('[MXCHAT-EMBED] ' . $error_message);
5364 return $error_message;
5365 }
5366 }
5367 }
5368
5369 /**
5370 * Get bot-specific options for multi-bot functionality
5371 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
5372 */
5373 private function get_bot_options($bot_id = 'default') {
5374 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
5375
5376 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5377 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
5378 return array();
5379 }
5380
5381 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
5382
5383 if (!empty($bot_options)) {
5384 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
5385 if (isset($bot_options['similarity_threshold'])) {
5386 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
5387 }
5388 }
5389
5390 return is_array($bot_options) ? $bot_options : array();
5391 }
5392
5393 /**
5394 * Get bot-specific Pinecone configuration
5395 * Used in the knowledge retrieval functions
5396 */
5397 // Also add debugging to your get_bot_pinecone_config function
5398 private function get_bot_pinecone_config($bot_id = 'default') {
5399 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
5400
5401 // If default bot or multi-bot add-on not active, use default Pinecone config
5402 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5403 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
5404 $addon_options = get_option('mxchat_pinecone_addon_options', array());
5405 $config = array(
5406 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
5407 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
5408 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
5409 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
5410 );
5411 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
5412 return $config;
5413 }
5414
5415 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
5416
5417 // Hook for multi-bot add-on to provide bot-specific Pinecone config
5418 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
5419
5420 if (!empty($bot_pinecone_config)) {
5421 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
5422 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
5423 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
5424 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
5425 } else {
5426 //error_log("MXCHAT DEBUG: Filter returned empty config!");
5427 }
5428
5429 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
5430 }
5431
5432
5433 public function mxchat_ajax_dismiss_completed_status() {
5434 try {
5435 // Verify the request
5436 check_ajax_referer('mxchat_status_nonce', 'nonce');
5437
5438 if (!current_user_can('manage_options')) {
5439 wp_send_json_error('Unauthorized access');
5440 exit;
5441 }
5442
5443 $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
5444
5445 if ($card_type === 'pdf') {
5446 // Clear PDF status
5447 $pdf_url = get_transient('mxchat_last_pdf_url');
5448 if ($pdf_url) {
5449 delete_transient('mxchat_pdf_status_' . md5($pdf_url));
5450 delete_transient('mxchat_last_pdf_url');
5451 }
5452 } elseif ($card_type === 'sitemap') {
5453 // Clear sitemap status
5454 $sitemap_url = get_transient('mxchat_last_sitemap_url');
5455 if ($sitemap_url) {
5456 delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
5457 delete_transient('mxchat_last_sitemap_url');
5458 }
5459 }
5460
5461 wp_send_json_success(array('message' => 'Status dismissed successfully'));
5462
5463 } catch (Exception $e) {
5464 wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
5465 }
5466 }
5467
5468 /**
5469 * Render completed status cards on page load
5470 * This ensures completed processing status persists through page refreshes
5471 */
5472 public function mxchat_render_completed_status_cards() {
5473 $output = '';
5474
5475 // Check for completed PDF status
5476 $pdf_url = get_transient('mxchat_last_pdf_url');
5477 if ($pdf_url) {
5478 $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
5479 if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
5480 $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
5481 }
5482 }
5483
5484 // Check for completed sitemap status
5485 $sitemap_url = get_transient('mxchat_last_sitemap_url');
5486 if ($sitemap_url) {
5487 $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
5488 if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
5489 $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
5490 }
5491 }
5492
5493 return $output;
5494 }
5495
5496 /**
5497 * Render PDF status card HTML
5498 */
5499 private function mxchat_render_pdf_status_card($status, $pdf_url) {
5500 $html = '<div class="mxchat-status-card" data-card-type="pdf">';
5501 $html .= '<div class="mxchat-status-header">';
5502 $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
5503
5504 // Add dismiss button for completed status
5505 if ($status['status'] === 'complete' || $status['status'] === 'error') {
5506 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5507 }
5508
5509 // Process Batch button for processing status
5510 if ($status['status'] === 'processing') {
5511 $html .= '<button type="button" class="mxchat-manual-batch-btn"
5512 data-process-type="pdf"
5513 data-url="' . esc_attr($pdf_url) . '">
5514 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5515 }
5516
5517 // Add status badges
5518 if ($status['status'] === 'error') {
5519 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5520 } elseif ($status['status'] === 'complete') {
5521 if ($status['failed_pages'] > 0) {
5522 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5523 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
5524 } else {
5525 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5526 }
5527 }
5528
5529 $html .= '</div>'; // End header
5530
5531 // Progress bar
5532 $html .= '<div class="mxchat-progress-bar">';
5533 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5534 $html .= '</div>';
5535
5536 // Status details
5537 $html .= '<div class="mxchat-status-details">';
5538 $html .= '<p>' . sprintf(
5539 esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
5540 $status['processed_pages'],
5541 $status['total_pages'],
5542 $status['percentage']
5543 ) . '</p>';
5544
5545 // Show failed pages count if any
5546 if ($status['failed_pages'] > 0) {
5547 $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
5548 }
5549
5550 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5551 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5552
5553 // Add completion summary if available AND it's an array
5554 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5555 $summary = $status['completion_summary'];
5556 $html .= '<div class="mxchat-completion-summary">';
5557 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5558 $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
5559 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
5560 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
5561 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5562 $html .= '</div>';
5563 }
5564
5565 // Add failed pages list if any AND it's an array
5566 if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
5567 $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
5568 }
5569
5570 // Add error message if any
5571 if (isset($status['error']) && !empty($status['error'])) {
5572 $html .= '<div class="mxchat-error-notice">';
5573 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5574 $html .= '</div>';
5575 }
5576
5577 $html .= '</div>'; // End details
5578 $html .= '</div>'; // End card
5579
5580 return $html;
5581 }
5582 /**
5583 * Render sitemap status card HTML
5584 */
5585 private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
5586 $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
5587 $html .= '<div class="mxchat-status-header">';
5588 $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
5589
5590 // Add dismiss button for completed status
5591 if ($status['status'] === 'complete' || $status['status'] === 'error') {
5592 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5593 }
5594
5595 // Process Batch button for processing status
5596 if ($status['status'] === 'processing') {
5597 $html .= '<button type="button" class="mxchat-manual-batch-btn"
5598 data-process-type="sitemap"
5599 data-url="' . esc_attr($sitemap_url) . '">
5600 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5601 }
5602
5603 // Add status badges
5604 if ($status['status'] === 'error') {
5605 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5606 } elseif ($status['status'] === 'complete') {
5607 if ($status['failed_urls'] > 0) {
5608 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5609 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
5610 } else {
5611 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5612 }
5613 }
5614
5615 $html .= '</div>'; // End header
5616
5617 // Progress bar
5618 $html .= '<div class="mxchat-progress-bar">';
5619 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5620 $html .= '</div>';
5621
5622 // Status details
5623 $html .= '<div class="mxchat-status-details">';
5624 $html .= '<p>' . sprintf(
5625 esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
5626 $status['processed_urls'],
5627 $status['total_urls'],
5628 $status['percentage']
5629 ) . '</p>';
5630
5631 // Show failed URLs count if any
5632 if ($status['failed_urls'] > 0) {
5633 $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
5634 }
5635
5636 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5637 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5638
5639 // Add completion summary if available AND it's an array
5640 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5641 $summary = $status['completion_summary'];
5642 $html .= '<div class="mxchat-completion-summary">';
5643 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5644 $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
5645 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
5646 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
5647 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5648 $html .= '</div>';
5649 }
5650
5651 // Add error messages if any (but not the failed URLs list)
5652 if (!empty($status['error']) || !empty($status['last_error'])) {
5653 $html .= '<div class="mxchat-error-notice">';
5654
5655 if (!empty($status['error'])) {
5656 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5657 }
5658
5659 if (!empty($status['last_error'])) {
5660 $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
5661 }
5662
5663 $html .= '</div>';
5664 }
5665
5666 $html .= '</div>'; // End details
5667 $html .= '</div>'; // End card
5668
5669 return $html;
5670 }
5671
5672
5673 /**
5674 * Render failed pages list
5675 */
5676 private function mxchat_render_failed_pages_list($failed_pages_list) {
5677 // Validate that $failed_pages_list is an array and not empty
5678 if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
5679 return '';
5680 }
5681
5682 $html = '<div class="mxchat-error-notice">';
5683 $html .= '<div class="mxchat-failed-pages-container">';
5684 $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
5685 $html .= '<details>';
5686 $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
5687 $html .= '<div class="mxchat-failed-pages-list">';
5688
5689 // Create table for failed pages
5690 $html .= '<table class="widefat striped">';
5691 $html .= '<thead><tr>';
5692 $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
5693 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5694 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5695 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5696 $html .= '</tr></thead><tbody>';
5697
5698 // Sort failed pages by most recent
5699 $sorted_failed_pages = $failed_pages_list;
5700 usort($sorted_failed_pages, function($a, $b) {
5701 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5702 });
5703
5704 foreach ($sorted_failed_pages as $item) {
5705 // Ensure $item is an array before accessing its elements
5706 if (!is_array($item)) {
5707 continue;
5708 }
5709
5710 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5711 $html .= '<tr>';
5712 $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
5713 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5714 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5715 $html .= '<td>' . esc_html($time_ago) . '</td>';
5716 $html .= '</tr>';
5717 }
5718
5719 $html .= '</tbody></table>';
5720 $html .= '</div></details></div></div>';
5721
5722 return $html;
5723 }
5724
5725 /**
5726 * Render failed URLs list
5727 */
5728 private function mxchat_render_failed_urls_list($failed_urls_list) {
5729 // Validate that $failed_urls_list is an array and not empty
5730 if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
5731 return '';
5732 }
5733
5734 $html = '<div class="mxchat-failed-urls-container">';
5735 $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
5736 $html .= '<details>';
5737 $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
5738 $html .= '<div class="mxchat-failed-urls-list">';
5739
5740 // Create table for failed URLs
5741 $html .= '<table class="widefat striped">';
5742 $html .= '<thead><tr>';
5743 $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
5744 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5745 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5746 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5747 $html .= '</tr></thead><tbody>';
5748
5749 // Sort failed URLs by most recent
5750 $sorted_failed_urls = $failed_urls_list;
5751 usort($sorted_failed_urls, function($a, $b) {
5752 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5753 });
5754
5755 // Show up to 50 failed URLs
5756 $display_urls = array_slice($sorted_failed_urls, 0, 50);
5757
5758 foreach ($display_urls as $item) {
5759 // Ensure $item is an array before accessing its elements
5760 if (!is_array($item)) {
5761 continue;
5762 }
5763
5764 $url = $item['url'] ?? '';
5765 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5766
5767 // Truncate URL for display
5768 $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
5769
5770 $html .= '<tr>';
5771 $html .= '<td style="word-break: break-all;">';
5772 if (!empty($url)) {
5773 $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
5774 } else {
5775 $html .= esc_html__('Unknown URL', 'mxchat');
5776 }
5777 $html .= '</td>';
5778 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5779 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5780 $html .= '<td>' . esc_html($time_ago) . '</td>';
5781 $html .= '</tr>';
5782 }
5783
5784 $html .= '</tbody></table>';
5785
5786 if (count($failed_urls_list) > 50) {
5787 $html .= '<div class="mxchat-failed-urls-more">+ ' .
5788 (count($failed_urls_list) - 50) .
5789 ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
5790 }
5791
5792 $html .= '</div></details></div>';
5793
5794 return $html;
5795 }
5796
5797 /**
5798 * Get all ACF fields for a specific post, excluding any fields the user has disabled
5799 */
5800 public function mxchat_get_acf_fields_for_post($post_id) {
5801 if (!function_exists('get_field_objects')) {
5802 return array();
5803 }
5804
5805 // Field OBJECTS, not get_fields(): exclusion matches on the field KEY
5806 // (unique per field) rather than the name (shared across groups — plan
5807 // 30e81f). ACF's own get_fields() is implemented as get_field_objects()
5808 // reduced to name => value, so the un-excluded reduction below is the
5809 // identical shape and order the previous get_fields() call produced.
5810 $field_objects = get_field_objects($post_id);
5811 if (!$field_objects || !is_array($field_objects)) {
5812 return array();
5813 }
5814
5815 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5816 if (!is_array($excluded_fields)) {
5817 $excluded_fields = array();
5818 }
5819
5820 $fields = array();
5821 foreach ($field_objects as $field_name => $field_object) {
5822 if (!empty($excluded_fields)) {
5823 $field_key = isset($field_object['key']) ? $field_object['key'] : '';
5824 // Legacy name entries stay honored: a stored name whose group was
5825 // inactive at migration time still excludes every field wearing it.
5826 if (in_array($field_key, $excluded_fields, true) || in_array($field_name, $excluded_fields, true)) {
5827 continue;
5828 }
5829 }
5830 $fields[$field_name] = isset($field_object['value']) ? $field_object['value'] : null;
5831 }
5832
5833 return $fields;
5834 }
5835
5836 /**
5837 * Get all registered ACF field groups and their fields for the settings UI
5838 */
5839 public function mxchat_get_all_acf_fields() {
5840 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5841 return array();
5842 }
5843
5844 // Keyed by GROUP KEY, not title (titles are not unique), and each field
5845 // entry carries its ACF field key — the unique identifier every toggle,
5846 // save, and index-time exclusion now runs on (plans 30e81f / bf57e0).
5847 $all_fields = array();
5848 $field_groups = acf_get_field_groups();
5849
5850 if (!empty($field_groups)) {
5851 foreach ($field_groups as $group) {
5852 $group_fields = acf_get_fields($group['key']);
5853 if (!empty($group_fields)) {
5854 $entry = array(
5855 'title' => $group['title'],
5856 'fields' => array(),
5857 );
5858 foreach ($group_fields as $field) {
5859 $entry['fields'][] = array(
5860 'key' => $field['key'],
5861 'name' => $field['name'],
5862 'label' => $field['label'],
5863 'type' => $field['type']
5864 );
5865 }
5866 $all_fields[$group['key']] = $entry;
5867 }
5868 }
5869 }
5870
5871 return $all_fields;
5872 }
5873
5874 /**
5875 * Get whitelisted custom post meta for a given post
5876 * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
5877 */
5878 public function mxchat_get_whitelisted_post_meta($post_id) {
5879 $whitelist = get_option('mxchat_custom_meta_whitelist', '');
5880
5881 if (empty($whitelist)) {
5882 return array();
5883 }
5884
5885 // Parse the whitelist - one meta key per line
5886 $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
5887
5888 if (empty($meta_keys)) {
5889 return array();
5890 }
5891
5892 $result = array();
5893
5894 foreach ($meta_keys as $key) {
5895 // Skip empty keys
5896 if (empty($key)) {
5897 continue;
5898 }
5899
5900 $value = get_post_meta($post_id, $key, true);
5901
5902 // Only include non-empty string values
5903 if (!empty($value) && is_string($value)) {
5904 $result[$key] = $value;
5905 } elseif (!empty($value) && is_array($value)) {
5906 // Handle array values by joining them
5907 $flat_value = $this->mxchat_flatten_meta_array($value);
5908 if (!empty($flat_value)) {
5909 $result[$key] = $flat_value;
5910 }
5911 }
5912 }
5913
5914 return $result;
5915 }
5916
5917 /**
5918 * Flatten array meta values into a readable string
5919 */
5920 private function mxchat_flatten_meta_array($array, $depth = 0) {
5921 if ($depth > 3) {
5922 return ''; // Prevent infinite recursion
5923 }
5924
5925 $parts = array();
5926
5927 foreach ($array as $key => $value) {
5928 if (is_string($value) && !empty($value)) {
5929 $parts[] = $value;
5930 } elseif (is_array($value)) {
5931 $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
5932 if (!empty($nested)) {
5933 $parts[] = $nested;
5934 }
5935 }
5936 }
5937
5938 return implode(', ', $parts);
5939 }
5940
5941 /**
5942 * Format ACF field values for content extraction
5943 */
5944 public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
5945 if (empty($value)) {
5946 return '';
5947 }
5948
5949 // Handle WP_Post objects first (THIS IS THE KEY FIX)
5950 if ($value instanceof WP_Post) {
5951 return $value->post_title ?: '';
5952 }
5953
5954 // Handle other WP objects
5955 if (is_object($value)) {
5956 if (isset($value->post_title)) {
5957 return $value->post_title;
5958 } elseif (isset($value->display_name)) {
5959 return $value->display_name;
5960 } elseif (isset($value->name)) {
5961 return $value->name;
5962 } elseif (method_exists($value, '__toString')) {
5963 try {
5964 return (string) $value;
5965 } catch (Exception $e) {
5966 return '';
5967 }
5968 }
5969 // For any other objects, return empty string
5970 return '';
5971 }
5972
5973 // Handle different ACF field types
5974 if (is_array($value)) {
5975 // Check if it's an image/file field
5976 if (isset($value['url'])) {
5977 // Image field - return alt text, title, or caption
5978 if (!empty($value['alt'])) {
5979 return $value['alt'];
5980 } elseif (!empty($value['title'])) {
5981 return $value['title'];
5982 } elseif (!empty($value['caption'])) {
5983 return $value['caption'];
5984 } else {
5985 return ''; // Don't include just the URL
5986 }
5987 }
5988
5989 // Check if it's a post object or relationship field
5990 if (isset($value['post_title'])) {
5991 return $value['post_title'];
5992 }
5993
5994 // Check if it's a user field
5995 if (isset($value['display_name'])) {
5996 return $value['display_name'];
5997 }
5998
5999 // Check if it's a taxonomy term
6000 if (isset($value['name']) && isset($value['taxonomy'])) {
6001 return $value['name'];
6002 }
6003
6004 // Check if it's a select field with label
6005 if (isset($value['label'])) {
6006 return $value['label'];
6007 }
6008
6009 // Check for repeater field or flexible content
6010 if (is_numeric(key($value))) {
6011 $sub_values = array();
6012 foreach ($value as $sub_item) {
6013 if (is_array($sub_item)) {
6014 // For repeater/flexible content, extract text values
6015 $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
6016 if (!empty($sub_text)) {
6017 $sub_values[] = $sub_text;
6018 }
6019 } elseif ($sub_item instanceof WP_Post) {
6020 // Handle WP_Post objects in arrays
6021 $sub_values[] = $sub_item->post_title ?: '';
6022 } else {
6023 $sub_values[] = (string) $sub_item;
6024 }
6025 }
6026 return implode(', ', array_filter($sub_values));
6027 }
6028
6029 // For other arrays, try to extract meaningful text
6030 $text_values = array();
6031 foreach ($value as $key => $val) {
6032 if (is_string($val) && !empty(trim($val))) {
6033 $text_values[] = trim($val);
6034 } elseif ($val instanceof WP_Post) {
6035 // Handle WP_Post objects in associative arrays
6036 $text_values[] = $val->post_title ?: '';
6037 } elseif (is_array($val) && isset($val['post_title'])) {
6038 $text_values[] = $val['post_title'];
6039 } elseif (is_array($val) && isset($val['name'])) {
6040 $text_values[] = $val['name'];
6041 }
6042 }
6043
6044 return implode(', ', array_filter($text_values));
6045 }
6046
6047 // Handle boolean values
6048 if (is_bool($value)) {
6049 return $value ? 'Yes' : 'No';
6050 }
6051
6052 // Handle numeric values
6053 if (is_numeric($value)) {
6054 return (string) $value;
6055 }
6056
6057 // Handle string values
6058 if (is_string($value)) {
6059 return trim($value);
6060 }
6061
6062 // For anything else that we can't handle, return empty string
6063 // This prevents the "Object could not be converted to string" error
6064 return '';
6065 }
6066
6067 /**
6068 * Extract text from complex ACF array structures
6069 */
6070 private function mxchat_extract_text_from_acf_array($array) {
6071 if (!is_array($array)) {
6072 return '';
6073 }
6074
6075 $text_parts = array();
6076
6077 foreach ($array as $key => $value) {
6078 if (is_string($value) && !empty(trim($value))) {
6079 // Skip keys that are likely to be IDs or technical values
6080 if (!is_numeric($value) || strlen($value) > 10) {
6081 $text_parts[] = trim($value);
6082 }
6083 } elseif ($value instanceof WP_Post) {
6084 // Handle WP_Post objects
6085 $text_parts[] = $value->post_title ?: '';
6086 } elseif (is_array($value)) {
6087 if (isset($value['post_title'])) {
6088 $text_parts[] = $value['post_title'];
6089 } elseif (isset($value['name'])) {
6090 $text_parts[] = $value['name'];
6091 } elseif (isset($value['label'])) {
6092 $text_parts[] = $value['label'];
6093 }
6094 } elseif (is_object($value)) {
6095 // Handle other objects safely
6096 if (isset($value->post_title)) {
6097 $text_parts[] = $value->post_title;
6098 } elseif (isset($value->name)) {
6099 $text_parts[] = $value->name;
6100 } elseif (isset($value->display_name)) {
6101 $text_parts[] = $value->display_name;
6102 }
6103 }
6104 }
6105
6106 return implode(', ', array_filter($text_parts));
6107 }
6108
6109 /**
6110 * Walk an ACF field value tree and collect attachment IDs for any value that
6111 * resolves to a PDF in the WordPress media library. Handles the three shapes
6112 * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
6113 * plain URL string), and recurses through repeater/group/flexible content.
6114 *
6115 * @param mixed $value The ACF field value (any depth)
6116 * @param array $out Accumulator (passed by reference) for attachment IDs
6117 * @param int $depth Recursion guard
6118 */
6119 private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
6120 if ($depth > 6) {
6121 return; // prevent runaway recursion on circular/very-deep structures
6122 }
6123
6124 if (empty($value)) {
6125 return;
6126 }
6127
6128 // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
6129 if (is_array($value)) {
6130 // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
6131 $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
6132 if ($looks_like_attachment) {
6133 $att_id = 0;
6134 if (!empty($value['ID']) && is_numeric($value['ID'])) {
6135 $att_id = (int) $value['ID'];
6136 } elseif (!empty($value['id']) && is_numeric($value['id'])) {
6137 $att_id = (int) $value['id'];
6138 } elseif (!empty($value['url']) && is_string($value['url'])) {
6139 $att_id = (int) attachment_url_to_postid($value['url']);
6140 }
6141
6142 $is_pdf = false;
6143 if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
6144 $is_pdf = true;
6145 } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
6146 $is_pdf = true;
6147 } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
6148 $is_pdf = true;
6149 } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
6150 $is_pdf = true;
6151 }
6152
6153 if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
6154 $out[] = $att_id;
6155 }
6156 // An array node that represents one attachment doesn't contain other
6157 // attachments inside it — done with this branch.
6158 return;
6159 }
6160
6161 // Recurse: repeater rows, flexible-content layouts, groups, etc.
6162 foreach ($value as $sub) {
6163 $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
6164 }
6165 return;
6166 }
6167
6168 // Plain numeric attachment ID (ACF File field set to "Return: ID")
6169 if (is_numeric($value)) {
6170 $att_id = (int) $value;
6171 if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6172 $out[] = $att_id;
6173 }
6174 return;
6175 }
6176
6177 // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
6178 if (is_string($value)) {
6179 $trimmed = trim($value);
6180 if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
6181 $att_id = (int) attachment_url_to_postid($trimmed);
6182 if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6183 $out[] = $att_id;
6184 }
6185 }
6186 return;
6187 }
6188 }
6189
6190 /**
6191 * Heuristic: does this URL/string look like a PDF reference?
6192 * Tolerates query strings and fragments (#page=2).
6193 */
6194 private function mxchat_url_looks_like_pdf($url) {
6195 if (!is_string($url) || $url === '') {
6196 return false;
6197 }
6198 // Strip query + fragment before checking extension
6199 $path = preg_replace('/[?#].*$/', '', $url);
6200 return (bool) preg_match('/\.pdf$/i', $path);
6201 }
6202
6203 /**
6204 * Extract text from a PDF attachment by ID using the bundled Smalot parser.
6205 * Reads the file directly from disk via get_attached_file (no HTTP fetch).
6206 * Result is cached on the attachment as post_meta keyed by file mtime so we
6207 * only parse the same PDF once unless the file changes on disk.
6208 *
6209 * @param int $attachment_id
6210 * @return string Extracted plain text, or '' on failure.
6211 */
6212 private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
6213 $attachment_id = (int) $attachment_id;
6214 if ($attachment_id <= 0) {
6215 return '';
6216 }
6217 if (get_post_mime_type($attachment_id) !== 'application/pdf') {
6218 return '';
6219 }
6220
6221 $pdf_path = get_attached_file($attachment_id);
6222 if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
6223 return '';
6224 }
6225
6226 // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
6227 // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
6228 $default_max_bytes = 25 * 1024 * 1024;
6229 $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
6230 if ($max_bytes > 0) {
6231 $file_size = @filesize($pdf_path);
6232 if ($file_size !== false && $file_size > $max_bytes) {
6233 error_log(sprintf(
6234 '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
6235 $attachment_id,
6236 basename($pdf_path),
6237 $file_size,
6238 $max_bytes
6239 ));
6240 return '';
6241 }
6242 }
6243
6244 $mtime = @filemtime($pdf_path);
6245 $cache_meta_key = '_mxchat_acf_pdf_text_v1';
6246 $cached = get_post_meta($attachment_id, $cache_meta_key, true);
6247 if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
6248 return (string) $cached['text'];
6249 }
6250
6251 $text = '';
6252 try {
6253 if (function_exists('mxchat_load_pdf_parser')) {
6254 mxchat_load_pdf_parser();
6255 }
6256 if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
6257 return '';
6258 }
6259 $parser = new \Smalot\PdfParser\Parser();
6260 $pdf = $parser->parseFile($pdf_path);
6261 $pages = $pdf->getPages();
6262 $page_texts = array();
6263 $acf_page_num = 0;
6264 foreach ($pages as $page) {
6265 $acf_page_num++;
6266 $page_text = '';
6267 try {
6268 $page_text = $page->getText();
6269 } catch (\Exception $e) {
6270 $page_text = '';
6271 }
6272 if (!empty($page_text)) {
6273 $page_text = MxChat_Utils::normalize_pdf_rtl($page_text, 'acf_pdf attachment ' . $attachment_id . ' page ' . $acf_page_num);
6274 $page_texts[] = $page_text;
6275 }
6276 }
6277 $text = trim(implode("\n\n", $page_texts));
6278 } catch (\Exception $e) {
6279 error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
6280 return '';
6281 } catch (\Throwable $e) {
6282 error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
6283 return '';
6284 }
6285
6286 // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
6287 // The chunker downstream will still split this into multiple vectors.
6288 $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
6289 if ($max_len > 0 && strlen($text) > $max_len) {
6290 $text = substr($text, 0, $max_len);
6291 }
6292
6293 update_post_meta($attachment_id, $cache_meta_key, array(
6294 'mtime' => (int) $mtime,
6295 'text' => $text,
6296 ));
6297
6298 return $text;
6299 }
6300
6301 /**
6302 * The media-library MIME types the knowledge base can actually read (plan 01010a).
6303 *
6304 * The importer's post-type dropdown is built from get_post_types(['public' => true]),
6305 * and 'attachment' is public — so Media has always been OFFERED as a source. What it
6306 * could never do is return anything, and the fix is deliberately narrower than the
6307 * offer: only files one of the THREE extractors already shipping in this plugin can
6308 * turn into text belong on that list. Images, audio and video are not listed at all
6309 * rather than listed and greyed out, because "nothing to import here" is a clearer
6310 * answer than a row you cannot select.
6311 *
6312 * text/markdown has no entry in WordPress's default wp_get_mime_types(), so a .md
6313 * file is only ever in the library on a site that added the mapping itself; it stays
6314 * on the list for those sites and costs nothing on the rest.
6315 *
6316 * @return array MIME type strings.
6317 */
6318 public static function mxchat_supported_attachment_mime_types() {
6319 /**
6320 * Filter the attachment MIME types offered by the WordPress Content importer.
6321 *
6322 * Adding a type here makes it listable and importable — mxchat_extract_attachment_text()
6323 * reads anything text/* as plain text, so a text-shaped MIME works out of the box.
6324 * A binary format needs its own extractor.
6325 *
6326 * @param array $mime_types Supported MIME types.
6327 */
6328 return (array) apply_filters('mxchat_kb_attachment_mime_types', array(
6329 'application/pdf',
6330 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
6331 'text/plain',
6332 'text/markdown',
6333 ));
6334 }
6335
6336 /**
6337 * Short human label for a supported attachment MIME type ("PDF", "Word", ...).
6338 * Used in the importer list, where "attachment" as a type and "0 words" as a size
6339 * would tell the user nothing about what they are about to import.
6340 */
6341 public static function mxchat_attachment_type_label($mime_type) {
6342 $labels = array(
6343 'application/pdf' => __('PDF', 'mxchat'),
6344 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => __('Word', 'mxchat'),
6345 'text/plain' => __('Text', 'mxchat'),
6346 'text/markdown' => __('Markdown', 'mxchat'),
6347 );
6348
6349 return isset($labels[$mime_type]) ? $labels[$mime_type] : __('Document', 'mxchat');
6350 }
6351
6352 /**
6353 * Extract indexable text from a media-library attachment (plan 01010a).
6354 *
6355 * Wiring, not parsing: every extractor here already ships and is already used
6356 * elsewhere — the Smalot path via mxchat_extract_pdf_text_by_attachment_id() (ACF
6357 * PDFs), MXChat_Word_Handler::extract_docx_text() and the plain-text read (both from
6358 * Document Upload, which is also where the character-estimated page cap comes from).
6359 * No new parser was added for this.
6360 *
6361 * Returns a reason instead of an empty string on failure so the caller can say WHY a
6362 * file was skipped; an attachment that yields no text must never be stored as an
6363 * empty entry.
6364 *
6365 * @param int $attachment_id
6366 * @return array{text: string, reason: string} reason is '' on success.
6367 */
6368 private function mxchat_extract_attachment_text($attachment_id) {
6369 $attachment_id = (int) $attachment_id;
6370 $mime_type = get_post_mime_type($attachment_id);
6371
6372 if (!in_array($mime_type, self::mxchat_supported_attachment_mime_types(), true)) {
6373 return array(
6374 'text' => '',
6375 'reason' => __('Unsupported file type — only PDF, Word, text and Markdown files can be imported.', 'mxchat'),
6376 );
6377 }
6378
6379 // PDF: the shared extractor owns its own size cap (25 MB raw, filterable), its
6380 // 50,000-character output cap, the RTL normaliser and the mtime-keyed cache, so
6381 // re-importing the same PDF does not re-parse it.
6382 if ($mime_type === 'application/pdf') {
6383 $text = $this->mxchat_extract_pdf_text_by_attachment_id($attachment_id);
6384 $text = trim((string) $text);
6385
6386 if ($text === '') {
6387 return array(
6388 'text' => '',
6389 'reason' => __('No extractable text — the PDF is empty, scanned images only, or larger than the size limit.', 'mxchat'),
6390 );
6391 }
6392
6393 return array('text' => $text, 'reason' => '');
6394 }
6395
6396 $file_path = get_attached_file($attachment_id);
6397 if (empty($file_path) || !file_exists($file_path) || !is_readable($file_path)) {
6398 return array(
6399 'text' => '',
6400 'reason' => __('The file could not be read from disk.', 'mxchat'),
6401 );
6402 }
6403
6404 if ($mime_type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
6405 $text = MXChat_Word_Handler::extract_docx_text($file_path);
6406 if ($text === false) {
6407 return array(
6408 'text' => '',
6409 'reason' => __('The Word file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
6410 );
6411 }
6412 $text = trim((string) $text);
6413 } else {
6414 // .txt / .md read as-is, the same way Document Upload reads them —
6415 // Markdown deliberately keeps its syntax, because headings are useful
6416 // retrieval signal.
6417 $text = (string) file_get_contents($file_path);
6418 $text = wp_check_invalid_utf8($text);
6419 $text = trim($text);
6420 }
6421
6422 if ($text === '') {
6423 return array(
6424 'text' => '',
6425 'reason' => __('The file contains no readable text.', 'mxchat'),
6426 );
6427 }
6428
6429 // Same cap Document Upload applies to the same three formats: the pdf_max_pages
6430 // setting, estimated by CHARACTERS (~2500/page) because the .docx cleaner
6431 // collapses newlines and a paragraph count reads 1 for any Word file. Importing
6432 // is synchronous, so an unbounded document risks a timeout.
6433 $options = get_option('mxchat_options', array());
6434 $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
6435 $estimated_pages = (int) ceil(strlen($text) / 2500);
6436 if ($max_pages > 0 && $estimated_pages > $max_pages) {
6437 return array(
6438 'text' => '',
6439 'reason' => sprintf(
6440 /* translators: 1: estimated page count, 2: configured page limit */
6441 __('Too large (about %1$d pages; the limit is %2$d). Split the file, or raise the PDF max pages setting.', 'mxchat'),
6442 $estimated_pages,
6443 $max_pages
6444 ),
6445 );
6446 }
6447
6448 return array('text' => $text, 'reason' => '');
6449 }
6450
6451 /**
6452 * Assemble the stored text for an attachment: title first, then the extracted body.
6453 * Mirrors the post assembler's shape (title on its own line, blank line, content) so
6454 * a media entry retrieves on its filename/title the same way a post does.
6455 */
6456 private function mxchat_build_attachment_content($post, $extracted_text) {
6457 $title = $this->mxchat_decode_entities_for_indexing($post->post_title);
6458 $parts = array($title);
6459
6460 // The media modal's Caption and Description fields are real, editable metadata
6461 // and are frequently the only human-written context a file has.
6462 $caption = trim(wp_strip_all_tags((string) $post->post_excerpt));
6463 if ($caption !== '') {
6464 $parts[] = $this->mxchat_decode_entities_for_indexing($caption);
6465 }
6466
6467 $description = trim(wp_strip_all_tags((string) $post->post_content));
6468 if ($description !== '') {
6469 $parts[] = $this->mxchat_decode_entities_for_indexing($description);
6470 }
6471
6472 $parts[] = $extracted_text;
6473
6474 return implode("\n\n", array_filter($parts, function ($part) {
6475 return $part !== '';
6476 }));
6477 }
6478
6479 /**
6480 * Import one media-library attachment into the knowledge base — the attachment
6481 * branch of ajax_mxchat_process_selected_content (plan 01010a).
6482 *
6483 * Separate from the post path rather than folded into it because almost every step
6484 * differs: the source URL is the FILE url (get_permalink() on an attachment returns
6485 * its attachment page, which is not what anyone means by "where this document
6486 * lives"), the body comes from an extractor instead of post_content, and a file that
6487 * yields no text has to be refused with a reason rather than stored empty.
6488 *
6489 * Sends its own JSON response and exits, exactly like its caller.
6490 */
6491 private function mxchat_import_attachment($post, $bot_id) {
6492 $post_id = (int) $post->ID;
6493
6494 $extracted = $this->mxchat_extract_attachment_text($post_id);
6495 if ($extracted['text'] === '') {
6496 wp_send_json_error(sprintf(
6497 /* translators: 1: attachment title, 2: reason the file was skipped */
6498 __('Skipped "%1$s": %2$s', 'mxchat'),
6499 $post->post_title,
6500 $extracted['reason']
6501 ));
6502 exit;
6503 }
6504
6505 $bot_options = $this->get_bot_options($bot_id);
6506 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6507
6508 $preflight = MxChat_Utils::embedding_preflight($options);
6509 if (!$preflight['ok']) {
6510 MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (attachment import)');
6511 wp_send_json_error($preflight['reason']);
6512 exit;
6513 }
6514
6515 $source_url = wp_get_attachment_url($post_id);
6516 if (empty($source_url)) {
6517 wp_send_json_error(__('The file has no URL — it may be missing from the uploads folder.', 'mxchat'));
6518 exit;
6519 }
6520
6521 $vector_id = md5($source_url);
6522 $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6523
6524 // "Already imported" read, same two-backend split the post path uses.
6525 $is_update = false;
6526 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6527 $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
6528
6529 if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
6530 $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
6531 if (isset($pinecone_data[$post_id])) {
6532 $is_update = true;
6533 }
6534 } else {
6535 global $wpdb;
6536 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6537 $existing_record = $wpdb->get_row($wpdb->prepare(
6538 "SELECT id FROM $table_name WHERE source_url = %s",
6539 $source_url
6540 ));
6541 if ($existing_record) {
6542 $is_update = true;
6543 }
6544 }
6545
6546 // content_type 'attachment' — NOT 'document'. The Knowledge screen's type filter
6547 // is built from public post types, so 'attachment' is exactly what its "Media"
6548 // option filters on; 'document' (Document Upload's label) would file these under
6549 // an option that dropdown does not offer.
6550 $result = MxChat_Utils::submit_content_to_db(
6551 $content,
6552 $source_url,
6553 $preflight['api_key'],
6554 $vector_id,
6555 $bot_id,
6556 'attachment'
6557 );
6558
6559 if (is_wp_error($result)) {
6560 MxChat_Admin::mxchat_log_debug('storage_error', 'Attachment storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
6561 wp_send_json_error('Storage failed: ' . $result->get_error_message());
6562 exit;
6563 }
6564
6565 // Attachments carry no tags, so the tag half of this is always a no-op — the
6566 // per-entry dropdown and the MxChat meta box on the attachment's own edit screen
6567 // are what restrict a media entry (plan 6ab002).
6568 $this->apply_role_restriction_to_post($post_id, $source_url);
6569
6570 $this->mxchat_remember_attachment_signature($post_id, $source_url, $content);
6571
6572 wp_send_json_success(array(
6573 'message' => $is_update ? 'Content updated successfully' : 'Content processed successfully',
6574 'post_id' => $post_id,
6575 'title' => $post->post_title,
6576 'operation_type' => $is_update ? 'update' : 'new',
6577 'vector_id' => $vector_id,
6578 'acf_fields_found' => 0,
6579 'pdf_extracted_count' => (get_post_mime_type($post_id) === 'application/pdf') ? 1 : 0,
6580 'content_preview' => substr($content, 0, 100) . '...',
6581 'bot_id' => $bot_id
6582 ));
6583 exit;
6584 }
6585
6586 /**
6587 * Is media auto-sync switched on? The toggle is the generic per-post-type one the
6588 * Advanced Custom Post Sync Settings list already renders for Media (attachment is a
6589 * public post type), so there is no new setting and no new UI for it.
6590 */
6591 private function mxchat_attachment_auto_sync_enabled() {
6592 return get_option('mxchat_auto_sync_attachment') === '1';
6593 }
6594
6595 /**
6596 * Record what we last indexed for an attachment: the URL it was stored under and a
6597 * hash of the stored text.
6598 *
6599 * This exists because 'edit_attachment' is noisy — it fires on every metadata touch,
6600 * including ones that change nothing we index (alt text, regenerating thumbnails).
6601 * Without a signature, each of those would pay for a fresh embedding call. The stored
6602 * URL is also what lets a file REPLACEMENT clean up after itself: the old URL's
6603 * vectors are deleted before the new ones are written, instead of being orphaned.
6604 */
6605 private function mxchat_remember_attachment_signature($attachment_id, $source_url, $content) {
6606 update_post_meta((int) $attachment_id, '_mxchat_kb_attachment_sig', array(
6607 'url' => (string) $source_url,
6608 'hash' => md5((string) $content),
6609 ));
6610 }
6611
6612 /**
6613 * Auto-sync an attachment on upload or edit (add_attachment / edit_attachment).
6614 *
6615 * Silent by design, like the post auto-sync path: a media upload is not a knowledge
6616 * base action from the user's point of view, so a file we cannot read is skipped
6617 * without an admin notice. The manual importer is where a skip gets a visible reason.
6618 */
6619 public function mxchat_handle_attachment_change($attachment_id) {
6620 $attachment_id = (int) $attachment_id;
6621
6622 if (!$this->mxchat_attachment_auto_sync_enabled()) {
6623 return;
6624 }
6625
6626 $post = get_post($attachment_id);
6627 if (!$post || $post->post_type !== 'attachment') {
6628 return;
6629 }
6630
6631 if (!in_array(get_post_mime_type($attachment_id), self::mxchat_supported_attachment_mime_types(), true)) {
6632 return;
6633 }
6634
6635 $source_url = wp_get_attachment_url($attachment_id);
6636 if (empty($source_url)) {
6637 return;
6638 }
6639
6640 $extracted = $this->mxchat_extract_attachment_text($attachment_id);
6641 if ($extracted['text'] === '') {
6642 return;
6643 }
6644
6645 $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6646 $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6647
6648 if (is_array($signature) && isset($signature['url'], $signature['hash'])) {
6649 // Nothing we index changed — don't pay for another embedding.
6650 if ($signature['url'] === $source_url && $signature['hash'] === md5($content)) {
6651 return;
6652 }
6653 // The file was replaced under a new URL. md5(old_url) vectors would survive
6654 // as an entry citing a file that no longer exists there, so clear them first.
6655 if ($signature['url'] !== $source_url && $signature['url'] !== '') {
6656 MxChat_Utils::delete_chunks_for_url($signature['url'], 'default');
6657 }
6658 }
6659
6660 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6661 if (!$preflight['ok']) {
6662 return;
6663 }
6664
6665 $result = MxChat_Utils::submit_content_to_db(
6666 $content,
6667 $source_url,
6668 $preflight['api_key'],
6669 md5($source_url),
6670 'default',
6671 'attachment'
6672 );
6673
6674 if (is_wp_error($result)) {
6675 return;
6676 }
6677
6678 $this->apply_role_restriction_to_post($attachment_id, $source_url);
6679 $this->mxchat_remember_attachment_signature($attachment_id, $source_url, $content);
6680 }
6681
6682 /**
6683 * Remove an attachment's knowledge entry when the file is deleted.
6684 *
6685 * Fires on 'delete_attachment', which is the ONLY delete hook an attachment gets —
6686 * wp_delete_post() hands attachments to wp_delete_attachment() before firing
6687 * before_delete_post, so mxchat_handle_post_delete never runs for one.
6688 *
6689 * Gated on the same auto-sync toggle as the post delete path, for the same reason it
6690 * is there: with auto-sync off, MxChat does not act on content changes by itself.
6691 */
6692 public function mxchat_handle_attachment_delete($attachment_id) {
6693 $attachment_id = (int) $attachment_id;
6694
6695 if (!$this->mxchat_attachment_auto_sync_enabled()) {
6696 return;
6697 }
6698
6699 // Still resolvable — this hook fires before the row and the file are removed.
6700 $source_url = wp_get_attachment_url($attachment_id);
6701 $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6702
6703 $urls = array();
6704 if (!empty($source_url)) {
6705 $urls[] = $source_url;
6706 }
6707 // A replaced-then-deleted file can still have vectors under the URL it was
6708 // last indexed at, if that differs from where it lives now.
6709 if (is_array($signature) && !empty($signature['url'])) {
6710 $urls[] = $signature['url'];
6711 }
6712
6713 foreach (array_unique($urls) as $url) {
6714 MxChat_Utils::delete_chunks_for_url($url, 'default');
6715 }
6716 }
6717
6718 /**
6719 * Handle ACF save - fires after ACF fields are saved
6720 * This ensures ACF field data is available when syncing to knowledge base
6721 */
6722 public function mxchat_handle_acf_save($post_id) {
6723 // Skip if not a valid post
6724 if (!$post_id || $post_id === 'options') {
6725 return;
6726 }
6727
6728 // Skip autosaves and revisions
6729 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6730 return;
6731 }
6732
6733 $post = get_post($post_id);
6734 if (!$post) {
6735 return;
6736 }
6737
6738 $post_type = $post->post_type;
6739
6740 // Check if sync is enabled for this post type
6741 $should_sync = false;
6742
6743 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6744 $should_sync = true;
6745 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6746 $should_sync = true;
6747 } else if ($post_type === 'product' && class_exists('WooCommerce')) {
6748 // WooCommerce products - check if WooCommerce integration is enabled
6749 $options = get_option('mxchat_options', array());
6750 if (isset($options['enable_woocommerce_integration']) &&
6751 ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
6752 $should_sync = true;
6753 }
6754 } else {
6755 // Check custom post types
6756 $option_name = 'mxchat_auto_sync_' . $post_type;
6757 if (get_option($option_name) === '1') {
6758 $should_sync = true;
6759 }
6760 }
6761
6762 if (!$should_sync) {
6763 return;
6764 }
6765
6766 // Only process published posts
6767 if ($post->post_status !== 'publish') {
6768 return;
6769 }
6770
6771 // Check if this post has any ACF fields - if not, no need to re-sync
6772 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6773 if (empty($acf_fields)) {
6774 return;
6775 }
6776
6777 // Use a transient to prevent duplicate processing (post_updated may have already run)
6778 $transient_key = 'mxchat_acf_synced_' . $post_id;
6779 if (get_transient($transient_key)) {
6780 return;
6781 }
6782 set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
6783
6784 // Re-run the sync with ACF data now available
6785 // We pass $update=true since this is effectively an update with ACF data
6786 $this->mxchat_handle_post_update($post_id, $post, true);
6787 }
6788
6789 public function mxchat_handle_post_update($post_id, $post, $update) {
6790 // The in-flight-update marker has done its job the moment post_updated runs; drop it
6791 // before any early return so it can never outlive its own save (a failed $wpdb->update
6792 // inside wp_insert_post returns after pre_post_update but before the transition).
6793 unset($this->pending_post_update[$post_id]);
6794
6795 // Basic validation checks
6796 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6797 return;
6798 }
6799
6800 $post_type = $post->post_type;
6801
6802 // Check if sync is enabled for this post type
6803 $should_sync = false;
6804
6805 // Check built-in post types first
6806 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6807 $should_sync = true;
6808 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6809 $should_sync = true;
6810 } else {
6811 // Check custom post types
6812 $option_name = 'mxchat_auto_sync_' . $post_type;
6813 if (get_option($option_name) === '1') {
6814 $should_sync = true;
6815 }
6816 }
6817
6818 if (!$should_sync) {
6819 return;
6820 }
6821
6822 // Check if we have stored the previous status and URL in our transients
6823 $previous_status_key = 'mxchat_prev_status_' . $post_id;
6824 $previous_status = get_transient($previous_status_key);
6825
6826 $previous_url_key = 'mxchat_prev_url_' . $post_id;
6827 $previous_url = get_transient($previous_url_key);
6828
6829 // If the post was previously published but is now not published, remove from knowledge base
6830 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
6831 // Use the stored URL from when it was published, or fall back to current permalink
6832 $source_url = $previous_url ?: get_permalink($post_id);
6833
6834 // mxchat_handle_status_transition already deleted for this post earlier in this
6835 // request (it fires first inside wp_insert_post); skip the redundant round-trip.
6836 if ($source_url && empty($this->transition_deleted_posts[$post_id])) {
6837 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6838 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6839 }
6840
6841 // Clean up the transients and exit early
6842 delete_transient($previous_status_key);
6843 delete_transient($previous_url_key);
6844 return;
6845 }
6846
6847 // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
6848 // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
6849 if ($post->post_status === 'publish' && !empty($previous_url)) {
6850 $current_url = get_permalink($post_id);
6851 if ($current_url && $current_url !== $previous_url) {
6852 MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
6853 }
6854 }
6855
6856 // Store the current status for next time (if this is an update)
6857 if ($update) {
6858 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
6859
6860 // If the post is currently published, also store its URL
6861 if ($post->post_status === 'publish') {
6862 $current_url = get_permalink($post_id);
6863 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
6864 }
6865 }
6866
6867 // Only process currently published content for adding/updating.
6868 // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have
6869 // already indexed this post earlier in this request (editor publishes fire
6870 // transition_post_status first, then post_updated) — skip the duplicate embed.
6871 // Consume-once: the flag is cleared when honoured, so a LATER save of the same
6872 // post in one long-running process (WP-CLI scripts, importers) re-indexes normally.
6873 if ($post->post_status === 'publish') {
6874 if (!empty($this->transition_indexed_posts[$post_id])) {
6875 unset($this->transition_indexed_posts[$post_id]);
6876 } else {
6877 $this->mxchat_index_published_post($post_id, $post);
6878 }
6879 }
6880
6881 // Clean up the stored previous status if not used above
6882 if ($previous_status !== 'publish' || $post->post_status === 'publish') {
6883 delete_transient($previous_status_key);
6884 delete_transient($previous_url_key);
6885 }
6886 }
6887
6888 /**
6889 * Index a published post into the knowledge base: preprocessing filter, content
6890 * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta,
6891 * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and
6892 * upsert, then tag-based role restriction.
6893 *
6894 * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the
6895 * transition_post_status arrival edge (mxchat_handle_status_transition), so
6896 * scheduled publishes (wp_publish_post) and direct status=publish inserts index
6897 * identically to editor saves (plan 3055e1). Pure extraction of the former
6898 * publish branch — body indentation retained to keep the diff reviewable.
6899 */
6900 private function mxchat_index_published_post($post_id, $post) {
6901 $post_type = $post->post_type;
6902
6903 // WooCommerce products are owned by the WC-object assembler (plan a3d60c):
6904 // whenever WooCommerce is active AND the integration is enabled, every
6905 // product save also fires save_post_product, which queues
6906 // mxchat_store_product_embedding on shutdown — and that writer runs LAST,
6907 // overwriting the same md5(permalink) row this path would write. Assembling
6908 // and embedding the product here was pure duplicate spend (measured: two
6909 // embedding calls per product save, second one wins). Skip ONLY under the
6910 // exact conditions the shutdown writer runs — same option read as its own
6911 // gate — because with the integration off (or WooCommerce inactive) this
6912 // path is the sole product indexer and must keep working.
6913 if ($post_type === 'product'
6914 && class_exists('WooCommerce')
6915 && isset($this->options['enable_woocommerce_integration'])
6916 && in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6917 return;
6918 }
6919
6920 // Get the source URL
6921 $source_url = get_permalink($post_id);
6922
6923 // A draft published programmatically (wp_publish_post) can reach this
6924 // point with an EMPTY post_name — wp_insert_post skips slug generation
6925 // for draft/pending — and get_permalink() then resolves to the bare
6926 // site root. A knowledge row keyed to the homepage cites the wrong URL
6927 // and answers homepage questions with this post's body, so refuse to
6928 // write it; the post indexes correctly on its next save, once the slug
6929 // exists. The empty-post_name test is what keeps a legitimate static
6930 // front page (which has a slug but a root permalink) indexable.
6931 // (Plan d138c4.)
6932 if ('' === $post->post_name
6933 && untrailingslashit($source_url) === untrailingslashit(home_url())) {
6934 return;
6935 }
6936
6937 /**
6938 * Allow developers to modify post data before processing into the knowledge base.
6939 * Same filter and signature as the manual bulk-import path
6940 * (ajax_mxchat_process_selected_content), so a callback registered once covers
6941 * every indexing route. Purely additive — zero behaviour change when unhooked.
6942 * Auto-sync runs under the 'default' bot context, matching the rest of this
6943 * function.
6944 *
6945 * @param WP_Post $post The post about to be indexed.
6946 * @param string $bot_id Bot context ('default' on auto-sync).
6947 */
6948 $post = apply_filters('mxchat_before_process_post', $post, 'default');
6949 if (!($post instanceof WP_Post)) {
6950 $post = get_post($post_id); // defend against a bad callback return
6951 }
6952
6953 // Assemble the indexable text via the shared post-kind assembler (a3d60c),
6954 // reading from the FILTERED post object — not re-fetched by ID, which would
6955 // discard it. Auto-sync reads content/excerpt in its historical
6956 // get_post_field() display context, never appended product custom tabs
6957 // (its product branch is reachable only with the WooCommerce integration
6958 // off), and gates ACF→PDF extraction behind its own opt-in option —
6959 // default OFF, because re-parsing every ACF PDF on every editor save is
6960 // expensive and most sites don't want it (the 25 MB size cap lives in the
6961 // shared extractor either way).
6962 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
6963 'read_display' => true,
6964 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1',
6965 'include_product_tabs' => false,
6966 ));
6967 $final_content = $prepared['content'];
6968
6969 // Embedding decision — custom-provider-aware. Gating on a cloud API key
6970 // here silently killed auto-sync on keyless custom-embeddings sites,
6971 // because generate_embedding() routes custom FIRST and never needs the
6972 // key (plan cbd5fd). Silent-return shape preserved.
6973 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6974 if (!$preflight['ok']) {
6975 return;
6976 }
6977 $api_key = $preflight['api_key'];
6978
6979 // Use the centralized utility function for storage
6980 $result = MxChat_Utils::submit_content_to_db(
6981 $final_content,
6982 $source_url,
6983 $api_key,
6984 md5($source_url) // Vector ID for Pinecone
6985 );
6986
6987 // After successful storage, apply role restriction based on tags
6988 if (!is_wp_error($result)) {
6989 $this->apply_role_restriction_to_post($post_id, $source_url);
6990 }
6991 }
6992
6993 /**
6994 * Shared post-fields content assembler (plan a3d60c) — the ONE body behind both
6995 * post-kind ingestion paths: manual bulk import (ajax_mxchat_process_selected_content)
6996 * and auto-sync (mxchat_index_published_post). Behavior-preserving extraction; the
6997 * measured per-caller differences ride $args instead of living as drifting copies:
6998 *
6999 * 'read_display' bool Auto-sync historically reads content/excerpt via
7000 * get_post_field() in its default 'display' context
7001 * (the post_content / post_excerpt display filters
7002 * fire); bulk import reads the raw properties. Inert
7003 * on a stock install — preserved per-path, not converged.
7004 * 'extract_acf_pdfs' bool Each caller passes its OWN option (bulk:
7005 * mxchat_acf_pdf_extraction; auto-sync:
7006 * mxchat_auto_sync_acf_pdfs) — the two-option design
7007 * is deliberate (plan 11720c). Gates BOTH the PDF-id
7008 * collection walk and the extraction loop; the ids are
7009 * only ever read inside the extraction branch, so
7010 * gating collection is output-identical on every install.
7011 * 'include_product_tabs' bool The bulk path has always appended yikes_woo custom
7012 * tabs to product content; the auto-sync product branch
7013 * (reachable only with the WooCommerce integration off)
7014 * never did. Preserved per-path — converging it would be
7015 * a behavior change, recorded on the plan instead.
7016 *
7017 * Returns array: 'content' (the assembled indexable text), 'acf_fields_found' and
7018 * 'pdf_extracted_count' (the bulk path reports both in its AJAX response).
7019 */
7020 private function mxchat_prepare_post_content_for_indexing($post_id, $post, $args) {
7021 $read_display = !empty($args['read_display']);
7022 $extract_acf_pdfs = !empty($args['extract_acf_pdfs']);
7023 $include_product_tabs = !empty($args['include_product_tabs']);
7024
7025 // Raw post_title, NOT get_the_title(): the_title applies wptexturize +
7026 // convert_chars and prepends the "Protected:" / "Private:" display chrome.
7027 // The knowledge base stores facts, not display strings. Entity decode at
7028 // output time (single-pass, shared helper) — a stored `&amp;` embeds worse
7029 // than `&` and gets quoted back to visitors (d2c92e).
7030 $content = $this->mxchat_decode_entities_for_indexing($post->post_title) . "\n\n";
7031
7032 $raw_excerpt = $read_display ? get_post_field('post_excerpt', $post) : $post->post_excerpt;
7033 $raw_content = $read_display ? get_post_field('post_content', $post) : $post->post_content;
7034
7035 // Add short description if it exists (WooCommerce products use post_excerpt for short description)
7036 // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to
7037 // empty, and testing the raw value emitted a bare "Short Description: " label
7038 // with no value after it. trim() only in the TEST — the emitted value is
7039 // untouched, so a populated excerpt is byte-identical to before. A
7040 // whitespace-only excerpt is an empty excerpt and must not produce a labelled
7041 // line with nothing after it.
7042 $clean_excerpt = $this->strip_shortcode_tags_preserve_content($raw_excerpt);
7043 if (trim($clean_excerpt) !== '') {
7044 $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n";
7045 }
7046
7047 // Main content — remove shortcode tags but preserve content inside them, then
7048 // strip tags (don't use 'the_content' filter as it may re-add shortcodes).
7049 $clean_content = $this->strip_shortcode_tags_preserve_content($raw_content);
7050 $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content));
7051
7052 // WooCommerce product enrichment (post-fields kind). The WC-object assembler
7053 // (mxchat_prepare_product_content_for_indexing) owns product rows whenever the
7054 // integration is on; this branch serves the bulk import (all configurations)
7055 // and auto-sync with the integration off.
7056 if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
7057 $product = wc_get_product($post_id);
7058
7059 if ($product) {
7060 $content .= "\n";
7061 $content .= $this->mxchat_woo_product_summary_lines($product);
7062 }
7063
7064 if ($include_product_tabs) {
7065 $content .= $this->mxchat_woo_custom_tabs_text($post_id);
7066 }
7067 }
7068
7069 // For custom post types like job_listing, include additional fields
7070 if (get_post_type($post_id) === 'job_listing') {
7071 // Add job-specific meta if available
7072 $job_location = get_post_meta($post_id, '_job_location', true);
7073 if (!empty($job_location)) {
7074 $content .= "\n\nLocation: " . $job_location;
7075 }
7076
7077 // Get job type terms
7078 $job_types = get_the_terms($post_id, 'job_listing_type');
7079 if (!empty($job_types) && !is_wp_error($job_types)) {
7080 $types = array();
7081 foreach ($job_types as $type) {
7082 $types[] = $type->name;
7083 }
7084 $content .= "\n\nJob Type: " . implode(', ', $types);
7085 }
7086
7087 // Get company name if available
7088 $company_name = get_post_meta($post_id, '_company_name', true);
7089 if (!empty($company_name)) {
7090 $content .= "\n\nCompany: " . $company_name;
7091 }
7092 }
7093
7094 // ADD ACF FIELDS SUPPORT
7095 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
7096 $pdf_extracted_count = 0;
7097 if (!empty($acf_fields)) {
7098 $acf_content_parts = array();
7099 $pdf_attachment_ids = array();
7100
7101 foreach ($acf_fields as $field_name => $field_value) {
7102 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
7103
7104 if (!empty($formatted_value)) {
7105 // Both separators: a hyphenated ACF name should read as words.
7106 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
7107 $acf_content_parts[] = $field_label . ": " . $formatted_value;
7108 }
7109
7110 // Walk this field's value tree for any PDF attachment references and
7111 // queue them for extraction — only when this caller's PDF option is on.
7112 if ($extract_acf_pdfs) {
7113 $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
7114 }
7115 }
7116
7117 if (!empty($acf_content_parts)) {
7118 $content .= "\n\n" . implode("\n", $acf_content_parts);
7119 }
7120
7121 // Extract text from each unique PDF found in ACF fields and append as a labeled section
7122 if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
7123 $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
7124 $pdf_sections = array();
7125 foreach ($pdf_attachment_ids as $att_id) {
7126 $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
7127 if (!empty($pdf_text)) {
7128 $pdf_title = get_the_title($att_id);
7129 $pdf_url = wp_get_attachment_url($att_id);
7130 $header = 'PDF Attachment';
7131 if (!empty($pdf_title)) {
7132 $header .= ': ' . $pdf_title;
7133 }
7134 if (!empty($pdf_url)) {
7135 $header .= ' (' . $pdf_url . ')';
7136 }
7137 $pdf_sections[] = $header . "\n" . $pdf_text;
7138 $pdf_extracted_count++;
7139 }
7140 }
7141 if (!empty($pdf_sections)) {
7142 $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
7143 }
7144 }
7145 }
7146
7147 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
7148 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
7149 if (!empty($custom_meta)) {
7150 $meta_content_parts = array();
7151
7152 foreach ($custom_meta as $meta_key => $meta_value) {
7153 // Convert meta key to readable label
7154 $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
7155 $meta_content_parts[] = $meta_label . ": " . $meta_value;
7156 }
7157
7158 if (!empty($meta_content_parts)) {
7159 $content .= "\n\n" . implode("\n", $meta_content_parts);
7160 }
7161 }
7162
7163 return array(
7164 'content' => $content,
7165 'acf_fields_found' => count($acf_fields),
7166 'pdf_extracted_count' => $pdf_extracted_count,
7167 );
7168 }
7169
7170 /**
7171 * Shared WC-object product assembler (plan a3d60c) — the ONE body behind the two
7172 * WooCommerce-object ingestion paths: the auto-sync product writer
7173 * (mxchat_store_product_embedding) and the URL/sitemap product import
7174 * (mxchat_extract_woocommerce_product_content). Assembles from the WC_Product,
7175 * the authoritative source for product rows (scope decision on the plan).
7176 */
7177 private function mxchat_prepare_product_content_for_indexing($product) {
7178 $title = $product->get_name();
7179 $description = $product->get_description();
7180 $short_description = $product->get_short_description();
7181
7182 // Format content consistently
7183 $content = $title . "\n\n";
7184
7185 if (!empty($short_description)) {
7186 $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7187 }
7188
7189 if (!empty($description)) {
7190 $content .= wp_strip_all_tags($description) . "\n\n";
7191 }
7192
7193 $content .= $this->mxchat_woo_product_summary_lines($product);
7194 $content .= $this->mxchat_woo_custom_tabs_text($product->get_id());
7195
7196 return $content;
7197 }
7198
7199 /**
7200 * Pricing + SKU + categories lines for a product — shared by both assembler kinds
7201 * (the post-fields product enrichment and the WC-object assembler).
7202 */
7203 private function mxchat_woo_product_summary_lines($product) {
7204 // Add pricing information (base-currency pinned — see mxchat_product_price_lines)
7205 $lines = $this->mxchat_product_price_lines($product);
7206
7207 $sku = $product->get_sku();
7208 if (!empty($sku)) {
7209 $lines .= "SKU: " . $sku . "\n";
7210 }
7211
7212 // Get product categories
7213 $categories = wp_get_post_terms($product->get_id(), 'product_cat', array('fields' => 'names'));
7214 if (!empty($categories) && !is_wp_error($categories)) {
7215 $lines .= "Categories: " . implode(', ', $categories) . "\n";
7216 }
7217
7218 return $lines;
7219 }
7220
7221 /**
7222 * Custom Product Tabs text (supports "Custom Product Tabs for WooCommerce" by
7223 * Code Parrots) — direct tabs plus applied reusable/saved tabs. The ONE copy of
7224 * the yikes_woo logic; three sites carried byte-identical clones before a3d60c.
7225 */
7226 private function mxchat_woo_custom_tabs_text($product_id) {
7227 $text = '';
7228
7229 $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7230 if (!empty($custom_tabs) && is_array($custom_tabs)) {
7231 foreach ($custom_tabs as $tab) {
7232 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7233 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7234
7235 if (!empty($tab_title) && !empty($tab_content)) {
7236 $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7237 }
7238 }
7239 }
7240
7241 // Also check for reusable/saved tabs applied to this product
7242 $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7243 if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7244 $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7245 if (!empty($saved_tabs) && is_array($saved_tabs)) {
7246 foreach ($applied_saved_tabs as $saved_tab_id) {
7247 if (isset($saved_tabs[$saved_tab_id])) {
7248 $tab = $saved_tabs[$saved_tab_id];
7249 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7250 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7251
7252 if (!empty($tab_title) && !empty($tab_content)) {
7253 $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7254 }
7255 }
7256 }
7257 }
7258 }
7259
7260 return $text;
7261 }
7262
7263 /**
7264 * Store the post status and URL before update to detect status transitions
7265 * This runs before the post is actually updated in the database
7266 */
7267 public function mxchat_store_pre_update_status($post_id, $data) {
7268 // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later
7269 // this request and can consume the arrival-edge guard (plan a664f3).
7270 $this->pending_post_update[$post_id] = true;
7271
7272 // Get the current post from database (before update)
7273 $current_post = get_post($post_id);
7274
7275 if ($current_post) {
7276 // Store the current status temporarily
7277 $status_key = 'mxchat_prev_status_' . $post_id;
7278 set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
7279
7280 // If the post is currently published, also store its URL
7281 if ($current_post->post_status === 'publish') {
7282 $url_key = 'mxchat_prev_url_' . $post_id;
7283 $current_url = get_permalink($post_id);
7284 set_transient($url_key, $current_url, HOUR_IN_SECONDS);
7285 }
7286 }
7287 }
7288
7289 /**
7290 * Whether auto-sync is enabled for a post type (mirrors the checks used by the
7291 * update/delete handlers; kept as one helper so new call sites cannot drift).
7292 */
7293 private function mxchat_is_auto_sync_enabled($post_type) {
7294 if ($post_type === 'post') {
7295 return get_option('mxchat_auto_sync_posts') === '1';
7296 }
7297 if ($post_type === 'page') {
7298 return get_option('mxchat_auto_sync_pages') === '1';
7299 }
7300 return get_option('mxchat_auto_sync_' . $post_type) === '1';
7301 }
7302
7303 /**
7304 * Remove a post's vectors the moment it leaves 'publish', using the authoritative
7305 * old status core passes to transition_post_status — no transient involved (plan 816fb1).
7306 *
7307 * Covers status changes that never route through wp_update_post (scheduled-expiry
7308 * plugins and others that flip post_status directly and call wp_transition_post_status),
7309 * where neither pre_post_update nor post_updated fires and the old detection missed.
7310 */
7311 public function mxchat_handle_status_transition($new_status, $old_status, $post) {
7312 if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) {
7313 return;
7314 }
7315
7316 // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because
7317 // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post —
7318 // and direct wp_insert_post(status=publish) creates never fire post_updated, so the
7319 // auto-sync ADD path alone misses them. Editor publishes also pass through here;
7320 // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding
7321 // a second time in the same request.
7322 if ($new_status === 'publish' && $old_status !== 'publish') {
7323 if ($this->mxchat_is_auto_sync_enabled($post->post_type)) {
7324 $this->mxchat_index_published_post($post->ID, $post);
7325
7326 // Arm the double-fire guard ONLY when a post_updated is actually coming to
7327 // consume it (plan a664f3). Two publish paths never fire post_updated at all:
7328 // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the
7329 // call check_and_publish_future_post() makes for scheduled posts. Arming the
7330 // guard unconditionally left it set with nothing to consume it, so the NEXT
7331 // update of that post was swallowed entirely: zero embed calls, no knowledge
7332 // -base row, silently. Consume-once on this side too, so a guard can never
7333 // outlive the single save it was armed for.
7334 if (!empty($this->pending_post_update[$post->ID])) {
7335 unset($this->pending_post_update[$post->ID]);
7336 $this->transition_indexed_posts[$post->ID] = true;
7337 }
7338 }
7339 return;
7340 }
7341
7342 // Only the publish -> not-publish edge matters here.
7343 if ($old_status !== 'publish' || $new_status === 'publish') {
7344 return;
7345 }
7346 // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL
7347 // resolution; skip to avoid a second network round-trip per trash.
7348 if ($new_status === 'trash') {
7349 return;
7350 }
7351 if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) {
7352 return;
7353 }
7354
7355 $urls = array();
7356
7357 // The DB may already hold the new status when this fires, so get_permalink() on the
7358 // live post could build a draft-style URL whose md5 misses the stored vector IDs.
7359 // Reconstruct the published permalink from a clone instead.
7360 $published_clone = clone $post;
7361 $published_clone->post_status = 'publish';
7362 $published_url = get_permalink($published_clone);
7363 if ($published_url) {
7364 $urls[] = $published_url;
7365 }
7366
7367 // Honour the pre-update capture when present (covers a slug change in the same save).
7368 $previous_url = get_transient('mxchat_prev_url_' . $post->ID);
7369 if (!empty($previous_url)) {
7370 $urls[] = $previous_url;
7371 }
7372
7373 foreach (array_unique($urls) as $url) {
7374 MxChat_Utils::delete_chunks_for_url($url, 'default');
7375 }
7376
7377 if (!empty($urls)) {
7378 $this->transition_deleted_posts[$post->ID] = true;
7379 }
7380 }
7381
7382 /**
7383 * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished,
7384 * trashed, or made private before the transition_post_status handler existed.
7385 *
7386 * Walks every auto-synced post type's non-published posts, reconstructs each one's
7387 * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table).
7388 * Deletion is idempotent, so never-indexed posts are a cheap no-op.
7389 *
7390 * ## OPTIONS
7391 *
7392 * [--dry-run]
7393 * : Report what would be removed without deleting anything.
7394 *
7395 * ## EXAMPLES
7396 *
7397 * wp mxchat prune-unpublished --dry-run
7398 * wp mxchat prune-unpublished
7399 */
7400 public function cli_prune_unpublished($args, $assoc_args) {
7401 global $wpdb;
7402 $dry_run = !empty($assoc_args['dry-run']);
7403 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7404
7405 $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names')));
7406 $synced_types = array();
7407 foreach ($candidate_types as $type) {
7408 if ($this->mxchat_is_auto_sync_enabled($type)) {
7409 $synced_types[] = $type;
7410 }
7411 }
7412 if (empty($synced_types)) {
7413 WP_CLI::success('No post types have auto-sync enabled; nothing to prune.');
7414 return;
7415 }
7416
7417 $scanned = 0;
7418 $pruned = 0;
7419 $paged = 1;
7420 do {
7421 $query = new WP_Query(array(
7422 'post_type' => $synced_types,
7423 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'),
7424 'posts_per_page' => 100,
7425 'paged' => $paged,
7426 'fields' => 'ids',
7427 ));
7428 foreach ($query->posts as $post_id) {
7429 $post = get_post($post_id);
7430 if (!$post) {
7431 continue;
7432 }
7433 $scanned++;
7434
7435 // Rebuild the permalink the post had while published: publish-status clone,
7436 // with wp_trash_post's __trashed slug suffix stripped for trashed posts.
7437 $clone = clone $post;
7438 $clone->post_status = 'publish';
7439 if (substr($clone->post_name, -9) === '__trashed') {
7440 $clone->post_name = substr($clone->post_name, 0, -9);
7441 }
7442 $url = get_permalink($clone);
7443 if (!$url) {
7444 continue;
7445 }
7446
7447 // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it
7448 // reads 0 but the delete below still routes to Pinecone and is idempotent.
7449 $local_rows = (int) $wpdb->get_var($wpdb->prepare(
7450 "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url
7451 ));
7452
7453 if ($dry_run) {
7454 if ($local_rows > 0) {
7455 WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7456 $pruned += $local_rows;
7457 }
7458 continue;
7459 }
7460
7461 MxChat_Utils::delete_chunks_for_url($url, 'default');
7462 if ($local_rows > 0) {
7463 WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7464 $pruned += $local_rows;
7465 }
7466 }
7467 $more = $paged < $query->max_num_pages;
7468 $paged++;
7469 } while ($more);
7470
7471 WP_CLI::success(sprintf(
7472 '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s',
7473 $dry_run ? 'Would remove' : 'Removed',
7474 $pruned,
7475 $scanned,
7476 ' (Pinecone-mode deletions are not counted locally.)'
7477 ));
7478 }
7479
7480 /**
7481 * WP-CLI: repair knowledge-base rows whose PDF text was imported in visual
7482 * (reversed) order before the RTL normalizer existed. 32bf9e fixed new
7483 * imports only; this fixes rows already in the table without the customer
7484 * having to re-source and re-upload the original PDFs (plan d1e6f7).
7485 *
7486 * Detection reuses MxChat_Utils::normalize_pdf_rtl() on the stored text: a
7487 * row is a candidate exactly when the normalizer would change it, so the
7488 * import-time heuristic and the repair heuristic can never disagree.
7489 * Repaired rows are RE-EMBEDDED — the stored vector was computed over
7490 * reversed text and is as broken as the text — so a wet run calls the
7491 * embedding provider once per repaired row on the site's API key. Runs
7492 * beyond 25 rows therefore require --yes.
7493 *
7494 * Scope notes:
7495 * - Scans the WordPress knowledge table. Pinecone-mode entries live in
7496 * Pinecone, not this table, and are not scanned; if a scanned row's bot
7497 * ALSO has Pinecone enabled (hybrid drift), the repaired entry is
7498 * re-submitted through the normal import path so the md5-keyed Pinecone
7499 * vector is replaced too.
7500 * - Knowledge rows do not carry a bot id; --bot only selects whose
7501 * embedding configuration (model + key) is used for re-embedding.
7502 * - The mxchat_pdf_rtl_normalize filter is honoured: a site that disabled
7503 * normalization gets detections of zero, not surprise rewrites.
7504 * - The metadata header the PDF importer stores before the text separator
7505 * is preserved byte-identical; only the text segment is repaired.
7506 *
7507 * ## OPTIONS
7508 *
7509 * [--dry-run]
7510 * : List the rows that would be repaired without changing anything.
7511 *
7512 * [--bot=<id>]
7513 * : Embedding configuration to use for re-embedding. Default: default.
7514 *
7515 * [--all-content]
7516 * : Scan every row containing right-to-left text, not just rows with PDF
7517 * provenance (a page anchor in the source URL, or pdf content type).
7518 *
7519 * [--yes]
7520 * : Proceed even when more than 25 rows need re-embedding (API cost gate).
7521 *
7522 * ## EXAMPLES
7523 *
7524 * wp mxchat rtl-repair --dry-run
7525 * wp mxchat rtl-repair
7526 * wp mxchat rtl-repair --all-content --yes
7527 */
7528 public function cli_rtl_repair($args, $assoc_args) {
7529 global $wpdb;
7530 $dry_run = !empty($assoc_args['dry-run']);
7531 $all = !empty($assoc_args['all-content']);
7532 $yes = !empty($assoc_args['yes']);
7533 $bot_id = isset($assoc_args['bot']) ? sanitize_key($assoc_args['bot']) : 'default';
7534 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7535
7536 // Detection pass — no API calls. Walk the table in id batches so a large
7537 // knowledge base never loads at once.
7538 $rtl_re = '/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u';
7539 $candidates = array();
7540 $scanned = 0;
7541 $last_id = 0;
7542 do {
7543 if ($all) {
7544 $rows = $wpdb->get_results($wpdb->prepare(
7545 "SELECT id, article_content, source_url, content_type FROM {$table}
7546 WHERE id > %d ORDER BY id ASC LIMIT 200",
7547 $last_id
7548 ));
7549 } else {
7550 $rows = $wpdb->get_results($wpdb->prepare(
7551 "SELECT id, article_content, source_url, content_type FROM {$table}
7552 WHERE id > %d AND (source_url LIKE %s OR content_type = 'pdf')
7553 ORDER BY id ASC LIMIT 200",
7554 $last_id,
7555 '%' . $wpdb->esc_like('#page=') . '%'
7556 ));
7557 }
7558 foreach ($rows as $row) {
7559 $last_id = (int) $row->id;
7560 $scanned++;
7561 $content = (string) $row->article_content;
7562 if (!preg_match($rtl_re, $content)) {
7563 continue;
7564 }
7565 list($header, $text) = $this->mxchat_rtl_repair_split($content);
7566 $normalized = MxChat_Utils::normalize_pdf_rtl($text, 'rtl-repair row ' . $row->id);
7567 if (is_string($normalized) && $normalized !== $text) {
7568 $candidates[] = array(
7569 'id' => (int) $row->id,
7570 'source_url' => (string) $row->source_url,
7571 'content_type' => (string) $row->content_type,
7572 'new_content' => $header . $normalized,
7573 );
7574 }
7575 }
7576 } while (count($rows) === 200);
7577
7578 WP_CLI::log(sprintf('Scanned %d row(s); %d stored in reversed (visual) order.', $scanned, count($candidates)));
7579 if (empty($candidates)) {
7580 WP_CLI::success('No reversed RTL rows found — nothing to repair.');
7581 return;
7582 }
7583
7584 foreach ($candidates as $c) {
7585 WP_CLI::log(sprintf('%s row %d %s', $dry_run ? 'Would repair' : 'Will repair', $c['id'], $c['source_url']));
7586 }
7587 if ($dry_run) {
7588 WP_CLI::success(sprintf('Dry run: %d row(s) would be repaired and re-embedded. Run without --dry-run to apply.', count($candidates)));
7589 return;
7590 }
7591
7592 // Cost gate: re-embedding spends the customer's API budget.
7593 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)));
7594 if (count($candidates) > 25 && !$yes) {
7595 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)));
7596 }
7597
7598 $bot_options = $this->get_bot_options($bot_id);
7599 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7600 $preflight = MxChat_Utils::embedding_preflight($options);
7601 if (!$preflight['ok']) {
7602 WP_CLI::error('Embedding configuration problem: ' . $preflight['reason']);
7603 }
7604 $api_key = $preflight['api_key'];
7605
7606 $pinecone_hybrid = $this->mxchat_rtl_repair_pinecone_enabled($bot_id);
7607 $repaired = 0;
7608 $failed = 0;
7609 foreach ($candidates as $c) {
7610 $vector = MxChat_Utils::regenerate_embedding($c['new_content'], $api_key, $bot_id);
7611 if (!is_array($vector)) {
7612 $failed++;
7613 $reason = is_wp_error($vector) ? $vector->get_error_message() : 'embedding request failed';
7614 // Text and vector must stay consistent: never write repaired text
7615 // beside the stale reversed-text vector.
7616 WP_CLI::warning(sprintf('Row %d NOT repaired — %s. Row left unchanged.', $c['id'], $reason));
7617 continue;
7618 }
7619 $wpdb->update(
7620 $table,
7621 array(
7622 'article_content' => $c['new_content'],
7623 'embedding_vector' => maybe_serialize($vector),
7624 ),
7625 array('id' => $c['id']),
7626 array('%s', '%s'),
7627 array('%d')
7628 );
7629 $repaired++;
7630 if (class_exists('MxChat_Admin')) {
7631 MxChat_Admin::mxchat_log_debug('pdf_rtl_repaired', 'Stored KB row restored to logical order and re-embedded', array(
7632 'row_id' => $c['id'],
7633 'source_url' => $c['source_url'],
7634 'bot' => $bot_id,
7635 ));
7636 }
7637 // Hybrid drift: the bot indexes into Pinecone but this row sat in the
7638 // WP table — push the repaired entry through the normal import path so
7639 // the md5(source_url)-keyed Pinecone vector is replaced as well.
7640 if ($pinecone_hybrid) {
7641 MxChat_Utils::submit_content_to_db(
7642 $c['new_content'],
7643 $c['source_url'],
7644 $api_key,
7645 null,
7646 $bot_id,
7647 $c['content_type'] !== '' ? $c['content_type'] : 'pdf'
7648 );
7649 }
7650 }
7651
7652 WP_CLI::success(sprintf('Repaired + re-embedded %d row(s); %d failed; %d scanned.', $repaired, $failed, $scanned));
7653 }
7654
7655 /**
7656 * Split a stored KB row into (metadata header incl. separator, text segment).
7657 * The PDF importer stores wp_json_encode($metadata) . "\n---\n" . $text —
7658 * repair must touch only the text and keep the header byte-identical.
7659 */
7660 private function mxchat_rtl_repair_split($content) {
7661 $sep = "\n---\n";
7662 $pos = strpos($content, $sep);
7663 if ($pos !== false && $pos > 0 && $content[0] === '{') {
7664 $maybe_json = substr($content, 0, $pos);
7665 if (json_decode($maybe_json) !== null) {
7666 return array(substr($content, 0, $pos + strlen($sep)), substr($content, $pos + strlen($sep)));
7667 }
7668 }
7669 return array('', $content);
7670 }
7671
7672 /**
7673 * Mirror of MxChat_Utils::is_pinecone_enabled_for_bot() (private there) for
7674 * the repair CLI's hybrid-drift check.
7675 */
7676 private function mxchat_rtl_repair_pinecone_enabled($bot_id) {
7677 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
7678 $cfg = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
7679 if (!empty($cfg)) {
7680 return !empty($cfg['use_pinecone']) && !empty($cfg['api_key']) && !empty($cfg['host']);
7681 }
7682 }
7683 $po = get_option('mxchat_pinecone_addon_options');
7684 return !empty($po['mxchat_use_pinecone']) && $po['mxchat_use_pinecone'] !== '0'
7685 && !empty($po['mxchat_pinecone_api_key']) && !empty($po['mxchat_pinecone_host']);
7686 }
7687
7688 public function mxchat_handle_post_delete($post_id) {
7689 // Get post data before it's deleted
7690 $post = get_post($post_id);
7691
7692 // Basic validation
7693 if (!$post || wp_is_post_revision($post_id)) {
7694 return;
7695 }
7696
7697 $post_type = $post->post_type;
7698
7699 // Check if sync is enabled for this post type
7700 $should_sync = false;
7701
7702 // Check built-in post types first
7703 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
7704 $should_sync = true;
7705 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
7706 $should_sync = true;
7707 } else {
7708 // Check custom post types
7709 $option_name = 'mxchat_auto_sync_' . $post_type;
7710 if (get_option($option_name) === '1') {
7711 $should_sync = true;
7712 }
7713 }
7714
7715 if (!$should_sync) {
7716 return;
7717 }
7718
7719 // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
7720 // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
7721 // real vector IDs stored under the original URL.
7722 $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
7723 if (!$source_url) {
7724 //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
7725 return;
7726 }
7727
7728 // Use chunk-aware deletion (handles both chunked and non-chunked content)
7729 $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
7730
7731 if (is_wp_error($delete_result)) {
7732 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
7733 }
7734
7735 delete_transient('mxchat_prev_url_' . $post_id);
7736 delete_transient('mxchat_prev_status_' . $post_id);
7737 }
7738
7739 /**
7740 * Resolve the source URL for a post being trashed/deleted.
7741 *
7742 * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
7743 * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
7744 * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
7745 * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
7746 */
7747 private function mxchat_resolve_pre_trash_url($post_id) {
7748 $previous_url = get_transient('mxchat_prev_url_' . $post_id);
7749 if (!empty($previous_url)) {
7750 return $previous_url;
7751 }
7752
7753 $current = get_permalink($post_id);
7754 if (!$current) {
7755 return '';
7756 }
7757 return preg_replace('#__trashed(/?)$#', '$1', $current);
7758 }
7759
7760
7761
7762 public function mxchat_handle_product_change($post_id, $post, $update) {
7763 if ($post->post_type !== 'product') {
7764 return;
7765 }
7766
7767 if ($post->post_status === 'publish') {
7768 add_action('shutdown', function() use ($post_id) {
7769 $product = wc_get_product($post_id);
7770 if ($product) {
7771 $this->mxchat_store_product_embedding($product);
7772 }
7773 });
7774 }
7775 }
7776
7777 /**
7778 * Store WooCommerce product embeddings
7779 */
7780 private function mxchat_store_product_embedding($product) {
7781 if (!isset($this->options['enable_woocommerce_integration']) ||
7782 !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
7783 return;
7784 }
7785
7786 $source_url = get_permalink($product->get_id());
7787 $product_id = $product->get_id();
7788
7789 // Build product content via the shared WC-object assembler (a3d60c) — this
7790 // writer owns product rows whenever the integration is on.
7791 $content = $this->mxchat_prepare_product_content_for_indexing($product);
7792
7793 // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return
7794 // shape preserved.
7795 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
7796 if (!$preflight['ok']) {
7797 //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']);
7798 return;
7799 }
7800 $api_key = $preflight['api_key'];
7801
7802 // Use the centralized utility function for storage
7803 $result = MxChat_Utils::submit_content_to_db(
7804 $content,
7805 $source_url,
7806 $api_key,
7807 md5($source_url) // Vector ID for Pinecone
7808 );
7809
7810 // After successful storage, apply role restriction based on tags
7811 if (!is_wp_error($result)) {
7812 $this->apply_role_restriction_to_post($product_id, $source_url);
7813 }
7814
7815 if (is_wp_error($result)) {
7816 //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
7817 }
7818 }
7819
7820 public function mxchat_handle_product_delete($post_id) {
7821 if (get_post_type($post_id) !== 'product') {
7822 return;
7823 }
7824
7825 $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
7826 if (!$source_url) {
7827 return;
7828 }
7829
7830 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
7831 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
7832
7833 delete_transient('mxchat_prev_url_' . $post_id);
7834 delete_transient('mxchat_prev_status_' . $post_id);
7835 }
7836
7837 /**
7838 * Handle individual Pinecone content deletion
7839 */
7840 public function mxchat_handle_pinecone_prompt_delete() {
7841 // Check permissions
7842 if (!current_user_can('manage_options')) {
7843 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
7844 }
7845
7846 // Verify nonce
7847 if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
7848 wp_die(esc_html__('Security check failed.', 'mxchat'));
7849 }
7850
7851 $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
7852
7853 if (empty($vector_id)) {
7854 set_transient('mxchat_admin_notice_error',
7855 esc_html__('Invalid vector ID.', 'mxchat'),
7856 30
7857 );
7858 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
7859 exit;
7860 }
7861
7862 // Get Pinecone settings
7863 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7864 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7865
7866 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
7867 set_transient('mxchat_admin_notice_error',
7868 esc_html__('Pinecone is not properly configured.', 'mxchat'),
7869 30
7870 );
7871 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
7872 exit;
7873 }
7874
7875 // Delete from Pinecone
7876 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7877 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
7878 $vector_id,
7879 $pinecone_options['mxchat_pinecone_api_key'],
7880 $pinecone_options['mxchat_pinecone_host'],
7881 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
7882 );
7883
7884 if ($result['success']) {
7885 // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6);
7886 // a chunk vector id reduces to its base entry there.
7887 if (class_exists('MxChat_Vectorstore_Manager')) {
7888 MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, 'default');
7889 }
7890 // No cache clearing needed since we removed caching
7891 set_transient('mxchat_admin_notice_success',
7892 esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
7893 30
7894 );
7895 } else {
7896 set_transient('mxchat_admin_notice_error',
7897 esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
7898 30
7899 );
7900 }
7901
7902 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
7903 exit;
7904 }
7905 /**
7906 * Handle individual Pinecone content deletion via AJAX
7907 */
7908 public function ajax_mxchat_delete_pinecone_prompt() {
7909 // Verify nonce and permissions
7910 if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
7911 wp_send_json_error('Invalid nonce');
7912 exit;
7913 }
7914
7915 if (!current_user_can('manage_options')) {
7916 wp_send_json_error('Unauthorized access');
7917 exit;
7918 }
7919
7920 $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
7921 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
7922
7923 if (empty($vector_id)) {
7924 wp_send_json_error('Missing vector ID');
7925 exit;
7926 }
7927
7928 // Get bot-specific Pinecone settings
7929 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7930 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
7931
7932 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7933
7934 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
7935 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
7936 exit;
7937 }
7938
7939 // Delete from the correct Pinecone index and namespace
7940 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
7941 $vector_id,
7942 $pinecone_options['mxchat_pinecone_api_key'],
7943 $pinecone_options['mxchat_pinecone_host'],
7944 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
7945 );
7946
7947 if ($result['success']) {
7948 // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6)
7949 if (class_exists('MxChat_Vectorstore_Manager')) {
7950 MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, $bot_id);
7951 }
7952 // No cache clearing needed since we removed caching
7953 wp_send_json_success(array(
7954 'message' => 'Entry deleted successfully from Pinecone',
7955 'vector_id' => $vector_id,
7956 'bot_id' => $bot_id
7957 ));
7958 } else {
7959 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
7960 wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
7961 }
7962
7963 exit;
7964 }
7965
7966 /**
7967 * Handle deletion of all chunks for a given source URL via AJAX
7968 * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
7969 */
7970 public function ajax_mxchat_delete_chunks_by_url() {
7971 // Verify nonce and permissions
7972 if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
7973 wp_send_json_error('Invalid nonce');
7974 exit;
7975 }
7976
7977 if (!current_user_can('manage_options')) {
7978 wp_send_json_error('Unauthorized access');
7979 exit;
7980 }
7981
7982 $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
7983 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
7984 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
7985
7986 if (empty($source_url)) {
7987 wp_send_json_error('Missing source URL');
7988 exit;
7989 }
7990
7991 // Generate the base vector ID from the source URL (same as how chunks are created)
7992 $base_vector_id = md5($source_url);
7993
7994 if ($data_source === 'pinecone') {
7995 // Get bot-specific Pinecone settings (same as working delete function)
7996 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7997 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
7998
7999 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
8000
8001 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
8002 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
8003 exit;
8004 }
8005
8006 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
8007 $host = $pinecone_options['mxchat_pinecone_host'];
8008 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
8009
8010 // Document index (plan 362c31): base id + chunk ids through the documents API.
8011 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
8012 $docs_cfg = MxChat_Pinecone_Documents::cfg_from_options($pinecone_options);
8013 $doc_ids = array_merge(array($base_vector_id), MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $docs_cfg));
8014 $doc_result = MxChat_Pinecone_Documents::delete_ids($doc_ids, $docs_cfg);
8015 if (is_wp_error($doc_result)) {
8016 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete documents from Pinecone: ' . $doc_result->get_error_message(), array('source_url' => $source_url));
8017 wp_send_json_error('Failed to delete from Pinecone: ' . $doc_result->get_error_message());
8018 exit;
8019 }
8020 if (class_exists('MxChat_Vectorstore_Manager')) {
8021 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8022 }
8023 wp_send_json_success(array(
8024 'message' => 'All chunks deleted successfully from Pinecone',
8025 'source_url' => $source_url,
8026 'deleted_count' => count($doc_ids)
8027 ));
8028 exit;
8029 }
8030
8031 // Collect all vector IDs to delete
8032 $vectors_to_delete = array();
8033
8034 // Add the original single-vector ID (for non-chunked content)
8035 $vectors_to_delete[] = $base_vector_id;
8036
8037 // Use Pinecone list API to find all chunk vectors with this prefix
8038 // NOTE: Pinecone List API is a GET request with query parameters, not POST
8039 $prefix = $base_vector_id . '_chunk_';
8040
8041 $query_params = array(
8042 'prefix' => $prefix,
8043 'limit' => 100
8044 );
8045
8046 if (!empty($namespace)) {
8047 $query_params['namespace'] = $namespace;
8048 }
8049
8050 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
8051
8052 $list_response = wp_remote_get($list_url, array(
8053 'headers' => array(
8054 'Api-Key' => $api_key,
8055 'accept' => 'application/json'
8056 ),
8057 'timeout' => 30
8058 ));
8059
8060 if (!is_wp_error($list_response)) {
8061 $list_body_response = wp_remote_retrieve_body($list_response);
8062 $list_data = json_decode($list_body_response, true);
8063 if (!empty($list_data['vectors'])) {
8064 foreach ($list_data['vectors'] as $vector) {
8065 if (isset($vector['id'])) {
8066 $vectors_to_delete[] = $vector['id'];
8067 }
8068 }
8069 }
8070 }
8071
8072 if (empty($vectors_to_delete)) {
8073 // Entry already gone from Pinecone — still clear any mirrored
8074 // Vector Store file so it can't outlive the entry (plan 15b5c6).
8075 if (class_exists('MxChat_Vectorstore_Manager')) {
8076 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8077 }
8078 wp_send_json_success(array(
8079 'message' => 'No vectors found to delete',
8080 'source_url' => $source_url
8081 ));
8082 exit;
8083 }
8084
8085 // Delete all vectors using the same endpoint as the working function
8086 $delete_url = "https://{$host}/vectors/delete";
8087
8088 $delete_body = array(
8089 'ids' => $vectors_to_delete
8090 );
8091
8092 if (!empty($namespace)) {
8093 $delete_body['namespace'] = $namespace;
8094 }
8095
8096 $delete_response = wp_remote_post($delete_url, array(
8097 'headers' => array(
8098 'Api-Key' => $api_key,
8099 'accept' => 'application/json',
8100 'content-type' => 'application/json'
8101 ),
8102 'body' => wp_json_encode($delete_body),
8103 'timeout' => 30
8104 ));
8105
8106 if (is_wp_error($delete_response)) {
8107 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
8108 wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
8109 exit;
8110 }
8111
8112 $response_code = wp_remote_retrieve_response_code($delete_response);
8113
8114 if ($response_code !== 200) {
8115 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
8116 wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
8117 exit;
8118 }
8119
8120 // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
8121 if (class_exists('MxChat_Vectorstore_Manager')) {
8122 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8123 }
8124
8125 wp_send_json_success(array(
8126 'message' => 'All chunks deleted successfully from Pinecone',
8127 'source_url' => $source_url,
8128 'deleted_count' => count($vectors_to_delete)
8129 ));
8130
8131 } else {
8132 // WordPress database deletion
8133 global $wpdb;
8134 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8135
8136 $result = $wpdb->delete(
8137 $table_name,
8138 array('source_url' => $source_url),
8139 array('%s')
8140 );
8141
8142 if ($result === false) {
8143 MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
8144 wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
8145 exit;
8146 }
8147
8148 // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
8149 if (class_exists('MxChat_Vectorstore_Manager')) {
8150 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8151 }
8152
8153 wp_send_json_success(array(
8154 'message' => 'All chunks deleted successfully from database',
8155 'source_url' => $source_url,
8156 'deleted_count' => $result
8157 ));
8158 }
8159
8160 exit;
8161 }
8162
8163 /**
8164 * Handle individual WordPress database content deletion via AJAX
8165 * Mirrors the Pinecone delete handler but for WordPress database entries
8166 */
8167 public function ajax_mxchat_delete_wordpress_prompt() {
8168 // Verify nonce and permissions
8169 if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
8170 wp_send_json_error('Invalid nonce');
8171 exit;
8172 }
8173
8174 if (!current_user_can('manage_options')) {
8175 wp_send_json_error('Unauthorized access');
8176 exit;
8177 }
8178
8179 $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
8180
8181 if (empty($entry_id)) {
8182 wp_send_json_error('Missing entry ID');
8183 exit;
8184 }
8185
8186 global $wpdb;
8187 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8188
8189 // Capture the identity BEFORE the row disappears — needed to mirror the
8190 // change into the Vector Store (plan 15b5c6).
8191 $source_url = $wpdb->get_var($wpdb->prepare(
8192 "SELECT source_url FROM {$table_name} WHERE id = %d",
8193 $entry_id
8194 ));
8195
8196 // Clear cache for this entry
8197 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
8198
8199 // Delete from database
8200 $result = $wpdb->delete(
8201 $table_name,
8202 array('id' => $entry_id),
8203 array('%d')
8204 );
8205
8206 if ($result !== false) {
8207 // Mirror to the Vector Store: if sibling rows remain (this was one
8208 // chunk of a larger entry) the entry's file is REFRESHED from what's
8209 // left; if none remain, the file is removed.
8210 if (!empty($source_url) && class_exists('MxChat_Vectorstore_Manager')) {
8211 $remaining = (int) $wpdb->get_var($wpdb->prepare(
8212 "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
8213 $source_url
8214 ));
8215 if ($remaining > 0) {
8216 MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, '', 'default');
8217 } else {
8218 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, 'default');
8219 }
8220 }
8221
8222 wp_send_json_success(array(
8223 'message' => 'Entry deleted successfully',
8224 'entry_id' => $entry_id
8225 ));
8226 } else {
8227 wp_send_json_error('Failed to delete entry from database');
8228 }
8229
8230 exit;
8231 }
8232
8233 /**
8234 * Handle bulk deletion of knowledge entries via AJAX
8235 * Supports both Pinecone and WordPress database entries
8236 */
8237 public function ajax_mxchat_bulk_delete_knowledge() {
8238 // Verify nonce and permissions
8239 if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
8240 wp_send_json_error('Invalid nonce');
8241 exit;
8242 }
8243
8244 if (!current_user_can('manage_options')) {
8245 wp_send_json_error('Unauthorized access');
8246 exit;
8247 }
8248
8249 $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
8250 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
8251
8252 if (empty($entries) || !is_array($entries)) {
8253 wp_send_json_error('No entries provided');
8254 exit;
8255 }
8256
8257 // Extend execution time — bulk Pinecone operations can take a while
8258 if (function_exists('set_time_limit')) {
8259 set_time_limit(120);
8260 }
8261
8262 $success_ids = array();
8263 $failed_ids = array();
8264 $errors = array();
8265
8266 global $wpdb;
8267 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8268
8269 // Get Pinecone manager for Pinecone deletions
8270 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
8271 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
8272 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
8273
8274 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
8275 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
8276 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
8277 // Document index (plan 362c31): list + delete go through the documents API below.
8278 $bulk_docs = class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options);
8279 $bulk_docs_cfg = $bulk_docs ? MxChat_Pinecone_Documents::cfg_from_options($pinecone_options) : array();
8280
8281 // =============================================
8282 // PHASE 1: Collect all Pinecone vector IDs
8283 // and separate WordPress entries
8284 // =============================================
8285 $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
8286 $wordpress_entries = array(); // entries for WordPress DB deletion
8287 $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
8288 $vs_mirror_urls = array(); // Vector Store mirror: URLs to delete (plan 15b5c6)
8289 $vs_mirror_keys = array(); // Vector Store mirror: bare vector ids to delete
8290
8291 foreach ($entries as $entry) {
8292 $entry_id = sanitize_text_field($entry['id'] ?? '');
8293 $source = sanitize_text_field($entry['source'] ?? 'wordpress');
8294 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
8295 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
8296
8297 if (empty($entry_id)) {
8298 continue;
8299 }
8300
8301 if ($source === 'pinecone') {
8302 if (!$use_pinecone || empty($api_key)) {
8303 $failed_ids[] = $entry_id;
8304 $errors[] = "Pinecone not configured for entry: $entry_id";
8305 continue;
8306 }
8307
8308 $pinecone_entry_ids[] = $entry_id;
8309
8310 if ($is_group && !empty($source_url)) {
8311 // Grouped/chunked entry: collect base ID + chunk IDs via List API
8312 $base_vector_id = md5($source_url);
8313 $all_vector_ids[] = $base_vector_id;
8314
8315 if ($bulk_docs) {
8316 $all_vector_ids = array_merge($all_vector_ids, MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $bulk_docs_cfg));
8317 } else {
8318 $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
8319 if (!empty($namespace)) {
8320 $list_url .= '&namespace=' . rawurlencode($namespace);
8321 }
8322 $list_response = wp_remote_get($list_url, array(
8323 'headers' => array(
8324 'Api-Key' => $api_key,
8325 'accept' => 'application/json'
8326 ),
8327 'timeout' => 30
8328 ));
8329
8330 if (!is_wp_error($list_response)) {
8331 $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
8332 if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
8333 foreach ($list_body['vectors'] as $vector) {
8334 if (isset($vector['id'])) {
8335 $all_vector_ids[] = $vector['id'];
8336 }
8337 }
8338 }
8339 }
8340 } // end classic (vector index) list
8341 } else {
8342 // Single entry: the entry_id IS the vector ID
8343 $all_vector_ids[] = $entry_id;
8344 }
8345
8346 if (!empty($source_url)) {
8347 $vs_mirror_urls[] = $source_url;
8348 } else {
8349 $vs_mirror_keys[] = $entry_id;
8350 }
8351 } else {
8352 $wordpress_entries[] = $entry;
8353 }
8354 }
8355
8356 // =============================================
8357 // PHASE 2: Single batch delete to Pinecone
8358 // =============================================
8359 if (!empty($all_vector_ids)) {
8360 $all_vector_ids = array_values(array_unique($all_vector_ids));
8361 $pinecone_success = true;
8362 $batches = array_chunk($all_vector_ids, 100);
8363
8364 // Document index (plan 362c31): one documents/delete call for every id.
8365 if ($bulk_docs) {
8366 $doc_result = MxChat_Pinecone_Documents::delete_ids($all_vector_ids, $bulk_docs_cfg);
8367 if (is_wp_error($doc_result)) {
8368 $pinecone_success = false;
8369 $errors[] = 'Pinecone batch deletion failed: ' . $doc_result->get_error_message();
8370 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete (document index) failed: ' . $doc_result->get_error_message());
8371 }
8372 } else
8373 foreach ($batches as $batch) {
8374 $delete_body = array('ids' => $batch);
8375 if (!empty($namespace)) {
8376 $delete_body['namespace'] = $namespace;
8377 }
8378 $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
8379 'headers' => array(
8380 'Api-Key' => $api_key,
8381 'accept' => 'application/json',
8382 'content-type' => 'application/json'
8383 ),
8384 'body' => wp_json_encode($delete_body),
8385 'timeout' => 60
8386 ));
8387
8388 if (is_wp_error($delete_response)) {
8389 $pinecone_success = false;
8390 $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
8391 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
8392 } else {
8393 $response_code = wp_remote_retrieve_response_code($delete_response);
8394 if ($response_code !== 200) {
8395 $pinecone_success = false;
8396 $response_body = wp_remote_retrieve_body($delete_response);
8397 $errors[] = "Pinecone API error (HTTP $response_code)";
8398 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
8399 }
8400 }
8401 }
8402
8403 // Mark all pinecone entries based on batch result
8404 foreach ($pinecone_entry_ids as $eid) {
8405 if ($pinecone_success) {
8406 $success_ids[] = $eid;
8407 } else {
8408 $failed_ids[] = $eid;
8409 }
8410 }
8411
8412 // Mirror the removals to the OpenAI Vector Store (plan 15b5c6)
8413 if ($pinecone_success && class_exists('MxChat_Vectorstore_Manager')) {
8414 foreach (array_unique($vs_mirror_urls) as $vs_url) {
8415 MxChat_Vectorstore_Manager::sync_delete_entry($vs_url, $bot_id);
8416 }
8417 foreach (array_unique($vs_mirror_keys) as $vs_key) {
8418 MxChat_Vectorstore_Manager::sync_delete_by_key($vs_key, $bot_id);
8419 }
8420 }
8421 }
8422
8423 // =============================================
8424 // PHASE 3: WordPress database deletions
8425 // =============================================
8426 foreach ($wordpress_entries as $entry) {
8427 $entry_id = sanitize_text_field($entry['id'] ?? '');
8428 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
8429 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
8430
8431 if (empty($entry_id)) {
8432 continue;
8433 }
8434
8435 try {
8436 if ($is_group && !empty($source_url)) {
8437 $result = $wpdb->delete(
8438 $table_name,
8439 array('source_url' => $source_url),
8440 array('%s')
8441 );
8442 $row_url = $source_url;
8443 } else {
8444 // Identity captured pre-delete for the Vector Store mirror
8445 $row_url = $wpdb->get_var($wpdb->prepare(
8446 "SELECT source_url FROM {$table_name} WHERE id = %d",
8447 intval($entry_id)
8448 ));
8449 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
8450 $result = $wpdb->delete(
8451 $table_name,
8452 array('id' => intval($entry_id)),
8453 array('%d')
8454 );
8455 }
8456
8457 if ($result !== false) {
8458 $success_ids[] = $entry_id;
8459 // Mirror to the Vector Store: refresh the entry's file when
8460 // sibling chunk rows survive, remove it when none do.
8461 if (!empty($row_url) && class_exists('MxChat_Vectorstore_Manager')) {
8462 $remaining = (int) $wpdb->get_var($wpdb->prepare(
8463 "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
8464 $row_url
8465 ));
8466 if ($remaining > 0) {
8467 MxChat_Vectorstore_Manager::sync_upsert_entry($row_url, '', $bot_id);
8468 } else {
8469 MxChat_Vectorstore_Manager::sync_delete_entry($row_url, $bot_id);
8470 }
8471 }
8472 } else {
8473 $failed_ids[] = $entry_id;
8474 $errors[] = "Database error for entry: $entry_id";
8475 }
8476 } catch (Exception $e) {
8477 $failed_ids[] = $entry_id;
8478 $errors[] = $e->getMessage();
8479 }
8480 }
8481
8482 wp_send_json_success(array(
8483 'success_ids' => $success_ids,
8484 'failed_ids' => $failed_ids,
8485 'errors' => $errors,
8486 'total_processed' => count($success_ids) + count($failed_ids)
8487 ));
8488
8489 exit;
8490 }
8491
8492 /**
8493 * Get the roles offered for a knowledge-entry restriction.
8494 *
8495 * Built from the site's ACTUAL roles (wp_roles()->get_names()) rather than a
8496 * hardcoded five, so roles added by User Role Editor / Members / a membership
8497 * plugin can be restricted to. The two virtual levels stay first, and the
8498 * built-in five keep their existing keys AND their existing labels, so
8499 * restrictions already stored on entries continue to mean what they meant.
8500 */
8501 public function mxchat_get_role_options() {
8502 return self::mxchat_role_options();
8503 }
8504
8505 /**
8506 * The same list, callable without an instance.
8507 *
8508 * The constructor registers hooks, so anything outside this class that needs
8509 * the role list (the post meta box) must not instantiate one just to read it.
8510 */
8511 public static function mxchat_role_options() {
8512 // Existing labels, in the existing order. A built-in that a site has
8513 // removed is simply not offered.
8514 $builtin_labels = array(
8515 'subscriber' => __('Subscribers & Above', 'mxchat'),
8516 'contributor' => __('Contributors & Above', 'mxchat'),
8517 'author' => __('Authors & Above', 'mxchat'),
8518 'editor' => __('Editors & Above', 'mxchat'),
8519 'administrator' => __('Administrators Only', 'mxchat'),
8520 );
8521
8522 $options = array(
8523 'public' => __('Public (Everyone)', 'mxchat'),
8524 'logged_in' => __('Logged In Users', 'mxchat'),
8525 );
8526
8527 $site_roles = function_exists('wp_roles') ? wp_roles()->get_names() : array();
8528 if (empty($site_roles) || !is_array($site_roles)) {
8529 // No roles readable (very early boot): fall back to the built-in set so
8530 // the dropdown is never empty.
8531 return array_merge($options, $builtin_labels);
8532 }
8533
8534 foreach ($builtin_labels as $slug => $label) {
8535 if (isset($site_roles[$slug])) {
8536 $options[$slug] = $label;
8537 }
8538 }
8539
8540 // Everything else the site defines, alphabetical by display name.
8541 $custom = array();
8542 foreach ($site_roles as $slug => $name) {
8543 if (!isset($builtin_labels[$slug])) {
8544 $custom[$slug] = translate_user_role($name);
8545 }
8546 }
8547 asort($custom);
8548
8549 return array_merge($options, $custom);
8550 }
8551
8552 /**
8553 * Check if the current user may see content carrying a role restriction.
8554 *
8555 * Access is granted when the user holds the required role outright, or holds
8556 * a role whose capabilities are a superset of the required role's. That keeps
8557 * the built-in "& Above" ladder exactly as it was — administrator's caps
8558 * cover editor's, editor's cover author's, and so on down to subscriber —
8559 * while working for any custom role, which a hardcoded five-name hierarchy
8560 * could not.
8561 *
8562 * The old hierarchy scored an unknown role 0, so restricting an entry TO a
8563 * custom role silently granted it to every logged-in user. Fails closed now.
8564 */
8565 public function mxchat_user_has_content_access($role_restriction) {
8566 // Public content is always accessible
8567 if ($role_restriction === 'public' || empty($role_restriction)) {
8568 return true;
8569 }
8570
8571 // Check if user is logged in for logged_in restriction
8572 if ($role_restriction === 'logged_in') {
8573 return is_user_logged_in();
8574 }
8575
8576 // If not logged in, no access to role-restricted content
8577 if (!is_user_logged_in()) {
8578 return false;
8579 }
8580
8581 $user = wp_get_current_user();
8582 $user_roles = (array) $user->roles;
8583
8584 if (empty($user_roles)) {
8585 return false;
8586 }
8587
8588 // Holding the required role is always enough.
8589 if (in_array($role_restriction, $user_roles, true)) {
8590 return true;
8591 }
8592
8593 // Site administrators keep the blanket access the old level-5 ladder gave
8594 // them. This also stops a restriction naming a since-deleted role from
8595 // locking the owner out of their own content.
8596 if (current_user_can('manage_options')) {
8597 return true;
8598 }
8599
8600 foreach ($user_roles as $user_role) {
8601 if (self::mxchat_role_covers($user_role, $role_restriction)) {
8602 return true;
8603 }
8604 }
8605
8606 return false;
8607 }
8608
8609 /**
8610 * Does $candidate_role grant everything $required_role grants?
8611 *
8612 * Compared on capabilities, not names. Memoised per request because this runs
8613 * once per retrieved knowledge row.
8614 *
8615 * A required role that does not exist, or that grants nothing at all, is NOT
8616 * treated as covered by anything — an empty capability set is a superset of
8617 * itself for every role on the site, which would quietly make the restriction
8618 * meaningless. Those cases fall back to the exact-role check in the caller.
8619 *
8620 * @param string $candidate_role Role slug the user holds.
8621 * @param string $required_role Role slug the entry is restricted to.
8622 * @return bool
8623 */
8624 private static function mxchat_role_covers($candidate_role, $required_role) {
8625 static $cache = array();
8626
8627 $key = $candidate_role . '|' . $required_role;
8628 if (isset($cache[$key])) {
8629 return $cache[$key];
8630 }
8631
8632 $cache[$key] = false;
8633
8634 if (!function_exists('wp_roles')) {
8635 return false;
8636 }
8637
8638 $roles = wp_roles();
8639 $candidate = $roles->get_role($candidate_role);
8640 $required = $roles->get_role($required_role);
8641
8642 if (!$candidate || !$required) {
8643 return false;
8644 }
8645
8646 $required_caps = self::mxchat_real_caps($required);
8647 $candidate_caps = self::mxchat_real_caps($candidate);
8648
8649 if (empty($required_caps)) {
8650 return false;
8651 }
8652
8653 foreach (array_keys($required_caps) as $cap) {
8654 if (empty($candidate_caps[$cap])) {
8655 return false;
8656 }
8657 }
8658
8659 $cache[$key] = true;
8660 return true;
8661 }
8662
8663 /**
8664 * A role's granted capabilities, minus the vestigial user-level ones.
8665 *
8666 * WordPress still stamps level_0 ... level_10 onto its built-in roles for
8667 * back-compat with user levels, deprecated since 3.0. They carry no meaning
8668 * and a hand-built custom role usually has none of them — so comparing them
8669 * makes a role that genuinely does more than a subscriber fail to cover
8670 * subscriber, purely because it lacks level_0. Measured on this box: a
8671 * Teacher role with read/edit_posts/delete_posts/upload_files was denied
8672 * subscriber-restricted content for exactly that reason.
8673 *
8674 * @param WP_Role $role
8675 * @return array Capability name => true, for granted non-level capabilities.
8676 */
8677 /**
8678 * Of two role restrictions, the one that admits FEWER people.
8679 *
8680 * 'public' loses to everything; 'logged_in' loses to any real role. Between
8681 * two real roles the comparison is the same capability-superset test the
8682 * access check uses: if A's capabilities cover B's, then everyone who
8683 * satisfies A also satisfies B, so A is the stricter requirement.
8684 *
8685 * Two unrelated custom roles are genuinely incomparable — neither covers the
8686 * other. In that case $b wins, because callers pass the per-entry choice as
8687 * $b: an owner who set the dropdown on the page itself meant it.
8688 *
8689 * @param string $a Current winner (may be 'public').
8690 * @param string $b Challenger.
8691 * @return string
8692 */
8693 private static function mxchat_stricter_role($a, $b) {
8694 $rank = array('public' => 0, 'logged_in' => 1);
8695
8696 if ($b === '' || $b === null) { return $a; }
8697 if ($a === '' || $a === null) { return $b; }
8698 if ($a === $b) { return $a; }
8699
8700 $a_virtual = isset($rank[$a]);
8701 $b_virtual = isset($rank[$b]);
8702
8703 if ($a_virtual && $b_virtual) {
8704 return $rank[$b] > $rank[$a] ? $b : $a;
8705 }
8706 // A real role always outranks 'public' and 'logged_in'.
8707 if ($a_virtual) { return $b; }
8708 if ($b_virtual) { return $a; }
8709
8710 // Both are real roles: whichever covers the other is the stricter one.
8711 if (self::mxchat_role_covers($a, $b)) { return $a; }
8712 if (self::mxchat_role_covers($b, $a)) { return $b; }
8713
8714 // Incomparable — the explicit per-entry choice wins.
8715 return $b;
8716 }
8717
8718 private static function mxchat_real_caps($role) {
8719 $caps = array();
8720 foreach ((array) $role->capabilities as $cap => $granted) {
8721 if (!$granted) {
8722 continue;
8723 }
8724 if (preg_match('/^level_\d+$/', $cap)) {
8725 continue;
8726 }
8727 $caps[$cap] = true;
8728 }
8729 return $caps;
8730 }
8731
8732 /**
8733 * Handle role restriction updates via AJAX
8734 * Removed cache clearing call since we removed caching
8735 */
8736 public function ajax_mxchat_update_role_restriction() {
8737 // Verify nonce and permissions
8738 if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
8739 wp_send_json_error('Invalid nonce');
8740 exit;
8741 }
8742
8743 if (!current_user_can('manage_options')) {
8744 wp_send_json_error('Unauthorized access');
8745 exit;
8746 }
8747
8748 $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
8749 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
8750 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
8751
8752 if (empty($entry_id)) {
8753 wp_send_json_error('Invalid entry ID');
8754 exit;
8755 }
8756
8757 // Get knowledge manager instance to validate role restriction
8758 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
8759 $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
8760 if (!in_array($role_restriction, $valid_roles)) {
8761 wp_send_json_error('Invalid role restriction');
8762 exit;
8763 }
8764
8765 global $wpdb;
8766
8767 if ($data_source === 'pinecone') {
8768 // Handle Pinecone role restriction (stored separately in WordPress table)
8769 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
8770
8771 // Use REPLACE to insert or update the role restriction
8772 $result = $wpdb->replace(
8773 $roles_table,
8774 array(
8775 'vector_id' => $entry_id,
8776 'role_restriction' => $role_restriction,
8777 'updated_at' => current_time('mysql')
8778 ),
8779 array('%s', '%s', '%s')
8780 );
8781
8782 // No cache clearing needed since we removed caching
8783
8784 } else {
8785 // Handle WordPress database role restriction (existing functionality)
8786 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8787
8788 $result = $wpdb->update(
8789 $table_name,
8790 array('role_restriction' => $role_restriction),
8791 array('id' => absint($entry_id)),
8792 array('%s'),
8793 array('%d')
8794 );
8795 }
8796
8797 if ($result === false) {
8798 wp_send_json_error('Database update failed: ' . $wpdb->last_error);
8799 exit;
8800 }
8801
8802 // Keep the OpenAI Vector Store mirror consistent with the new restriction
8803 // (plan 15b5c6): non-public pulls the mirrored file, public re-mirrors.
8804 if (class_exists('MxChat_Vectorstore_Manager')) {
8805 if ($data_source === 'pinecone') {
8806 if ($role_restriction !== 'public') {
8807 MxChat_Vectorstore_Manager::sync_delete_by_key($entry_id, 'default');
8808 }
8809 // Public again: Pinecone-mode content isn't held locally, so the
8810 // entry re-mirrors on its next save/import rather than here.
8811 } else {
8812 $row_url = $wpdb->get_var($wpdb->prepare(
8813 "SELECT source_url FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
8814 absint($entry_id)
8815 ));
8816 if (!empty($row_url)) {
8817 MxChat_Vectorstore_Manager::handle_role_change($row_url, 'default', $role_restriction);
8818 }
8819 }
8820 }
8821
8822 wp_send_json_success(array(
8823 'message' => 'Role restriction updated successfully',
8824 'role_restriction' => $role_restriction,
8825 'data_source' => $data_source,
8826 'entry_id' => $entry_id
8827 ));
8828 exit;
8829 }
8830
8831 // ========================================
8832 // ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
8833 // Add these to your MxChat_Knowledge_Manager class
8834 // ========================================
8835
8836 /**
8837 * Initialize role-based content hooks
8838 * Add this call to your __construct() or mxchat_init_hooks() method
8839 */
8840 private function mxchat_init_role_hooks() {
8841 // AJAX handlers for tag-role mappings
8842 add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
8843 add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
8844 add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
8845 add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
8846
8847 // Hook to automatically update role restrictions when tags are added/removed
8848 add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
8849
8850 // Hook to apply role restrictions on auto-sync
8851 add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
8852 }
8853
8854 /**
8855 * Add tag-role mapping via AJAX
8856 */
8857 public function ajax_add_tag_role_mapping() {
8858 // Verify nonce and permissions
8859 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
8860
8861 if (!current_user_can('manage_options')) {
8862 wp_send_json_error('Unauthorized access');
8863 exit;
8864 }
8865
8866 $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
8867 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
8868
8869 if (empty($tag_input)) {
8870 wp_send_json_error('Please enter a tag name or slug');
8871 exit;
8872 }
8873
8874 // Validate role restriction
8875 $valid_roles = array_keys($this->mxchat_get_role_options());
8876 if (!in_array($role_restriction, $valid_roles)) {
8877 wp_send_json_error('Invalid role restriction');
8878 exit;
8879 }
8880
8881 // Resolve the tag by slug first, then fall back to its display name, so users can
8882 // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
8883 // labeled by name but previously validated by slug only, producing the confusing
8884 // "Tag does not exist in WordPress" error when a real tag's name was typed.)
8885 $term = get_term_by('slug', $tag_input, 'post_tag');
8886 if (!$term) {
8887 $term = get_term_by('name', $tag_input, 'post_tag');
8888 }
8889 if (!$term) {
8890 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.');
8891 exit;
8892 }
8893
8894 // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
8895 // compares against each post's tag slugs, so the stored key must be a slug,
8896 // never the raw (possibly display-name) input.
8897 $tag_slug = $term->slug;
8898
8899 // Get existing mappings
8900 $mappings = get_option('mxchat_tag_role_mappings', array());
8901
8902 // Check if mapping already exists
8903 if (isset($mappings[$tag_slug])) {
8904 wp_send_json_error('Mapping for this tag already exists');
8905 exit;
8906 }
8907
8908 // Add new mapping
8909 $mappings[$tag_slug] = $role_restriction;
8910 update_option('mxchat_tag_role_mappings', $mappings);
8911
8912 wp_send_json_success(array(
8913 'message' => 'Tag-role mapping added successfully',
8914 'tag_slug' => $tag_slug,
8915 'role_restriction' => $role_restriction
8916 ));
8917 exit;
8918 }
8919
8920 /**
8921 * Delete tag-role mapping via AJAX
8922 */
8923 public function ajax_delete_tag_role_mapping() {
8924 // Verify nonce and permissions
8925 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
8926
8927 if (!current_user_can('manage_options')) {
8928 wp_send_json_error('Unauthorized access');
8929 exit;
8930 }
8931
8932 $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
8933
8934 if (empty($tag_slug)) {
8935 wp_send_json_error('Tag slug is required');
8936 exit;
8937 }
8938
8939 // Get existing mappings
8940 $mappings = get_option('mxchat_tag_role_mappings', array());
8941
8942 // Check if mapping exists
8943 if (!isset($mappings[$tag_slug])) {
8944 wp_send_json_error('Mapping does not exist');
8945 exit;
8946 }
8947
8948 // Remove mapping
8949 unset($mappings[$tag_slug]);
8950 update_option('mxchat_tag_role_mappings', $mappings);
8951
8952 wp_send_json_success(array(
8953 'message' => 'Tag-role mapping deleted successfully',
8954 'tag_slug' => $tag_slug
8955 ));
8956 exit;
8957 }
8958
8959 /**
8960 * Get all tag-role mappings via AJAX
8961 */
8962 public function ajax_get_tag_role_mappings() {
8963 // Verify nonce and permissions
8964 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
8965
8966 if (!current_user_can('manage_options')) {
8967 wp_send_json_error('Unauthorized access');
8968 exit;
8969 }
8970
8971 // Get mappings
8972 $mappings = get_option('mxchat_tag_role_mappings', array());
8973 $role_options = $this->mxchat_get_role_options();
8974
8975 $formatted_mappings = array();
8976
8977 foreach ($mappings as $tag_slug => $role_restriction) {
8978 // Get tag object
8979 $term = get_term_by('slug', $tag_slug, 'post_tag');
8980
8981 // Count posts with this tag
8982 $post_count = 0;
8983 if ($term) {
8984 $post_count = $term->count;
8985 }
8986
8987 $formatted_mappings[] = array(
8988 'tag_slug' => $tag_slug,
8989 'role_restriction' => $role_restriction,
8990 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
8991 'post_count' => $post_count
8992 );
8993 }
8994
8995 wp_send_json_success(array(
8996 'mappings' => $formatted_mappings
8997 ));
8998 exit;
8999 }
9000
9001 /**
9002 * Bulk update role restrictions for all existing content with mapped tags
9003 */
9004 public function ajax_bulk_update_tag_roles() {
9005 // Verify nonce and permissions
9006 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
9007
9008 if (!current_user_can('manage_options')) {
9009 wp_send_json_error('Unauthorized access');
9010 exit;
9011 }
9012
9013 // Get mappings
9014 $mappings = get_option('mxchat_tag_role_mappings', array());
9015
9016 if (empty($mappings)) {
9017 wp_send_json_error('No tag-role mappings found');
9018 exit;
9019 }
9020
9021 global $wpdb;
9022
9023 // Check if using Pinecone
9024 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
9025 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9026
9027 $updated_count = 0;
9028 $details = array();
9029
9030 foreach ($mappings as $tag_slug => $role_restriction) {
9031 // Get all posts with this tag
9032 $posts = get_posts(array(
9033 'tag' => $tag_slug,
9034 'post_type' => 'any',
9035 'posts_per_page' => -1,
9036 'fields' => 'ids',
9037 'post_status' => 'publish'
9038 ));
9039
9040 if (empty($posts)) {
9041 continue;
9042 }
9043
9044 $tag_updated = 0;
9045
9046 foreach ($posts as $post_id) {
9047 $source_url = get_permalink($post_id);
9048 if (!$source_url) {
9049 continue;
9050 }
9051
9052 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
9053 // Update Pinecone role restriction
9054 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
9055 $vector_id = md5($source_url);
9056
9057 $result = $wpdb->replace(
9058 $roles_table,
9059 array(
9060 'vector_id' => $vector_id,
9061 'role_restriction' => $role_restriction,
9062 'updated_at' => current_time('mysql')
9063 ),
9064 array('%s', '%s', '%s')
9065 );
9066 } else {
9067 // Update WordPress DB
9068 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9069
9070 $result = $wpdb->update(
9071 $table_name,
9072 array('role_restriction' => $role_restriction),
9073 array('source_url' => $source_url),
9074 array('%s'),
9075 array('%s')
9076 );
9077 }
9078
9079 if ($result !== false) {
9080 $tag_updated++;
9081 $updated_count++;
9082 }
9083 }
9084
9085 if ($tag_updated > 0) {
9086 $details[] = sprintf(
9087 'Tag "%s" (%s): %d posts updated',
9088 $tag_slug,
9089 $role_restriction,
9090 $tag_updated
9091 );
9092 }
9093 }
9094
9095 wp_send_json_success(array(
9096 'message' => 'Bulk update completed',
9097 'updated_count' => $updated_count,
9098 'tags_processed' => count($mappings),
9099 'details' => $details
9100 ));
9101 exit;
9102 }
9103
9104 /**
9105 * Handle tag changes on posts (when tags are added or removed)
9106 */
9107 public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
9108 // Only process post tags
9109 if ($taxonomy !== 'post_tag') {
9110 return;
9111 }
9112
9113 // Get tag-role mappings
9114 $mappings = get_option('mxchat_tag_role_mappings', array());
9115
9116 if (empty($mappings)) {
9117 return;
9118 }
9119
9120 // Get the post's URL
9121 $source_url = get_permalink($object_id);
9122 if (!$source_url) {
9123 return;
9124 }
9125
9126 // Determine the highest role restriction based on tags
9127 $highest_role = 'public';
9128 $role_hierarchy = array(
9129 'public' => 0,
9130 'logged_in' => 1,
9131 'subscriber' => 2,
9132 'contributor' => 3,
9133 'author' => 4,
9134 'editor' => 5,
9135 'administrator' => 6
9136 );
9137
9138 // Get all current tags for the post
9139 $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
9140
9141 // Find the highest role restriction among the tags
9142 foreach ($current_tags as $tag_slug) {
9143 if (isset($mappings[$tag_slug])) {
9144 $role = $mappings[$tag_slug];
9145 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
9146 $highest_role = $role;
9147 }
9148 }
9149 }
9150
9151 // Update the role restriction in the database
9152 global $wpdb;
9153
9154 // Check if using Pinecone
9155 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
9156 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9157
9158 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
9159 // Update Pinecone role restriction
9160 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
9161 $vector_id = md5($source_url);
9162
9163 $wpdb->replace(
9164 $roles_table,
9165 array(
9166 'vector_id' => $vector_id,
9167 'role_restriction' => $highest_role,
9168 'updated_at' => current_time('mysql')
9169 ),
9170 array('%s', '%s', '%s')
9171 );
9172 } else {
9173 // Update WordPress DB
9174 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9175
9176 $wpdb->update(
9177 $table_name,
9178 array('role_restriction' => $highest_role),
9179 array('source_url' => $source_url),
9180 array('%s'),
9181 array('%s')
9182 );
9183 }
9184
9185 // The entry's restriction just changed — keep the OpenAI Vector Store
9186 // mirror consistent: non-public pulls the file (file_search has no
9187 // per-role filtering), public re-mirrors it (plan 15b5c6).
9188 if (class_exists('MxChat_Vectorstore_Manager')) {
9189 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
9190 }
9191 }
9192
9193 /**
9194 * Apply role restriction after content is stored (for auto-sync)
9195 */
9196 public function apply_role_restriction_after_storage($post_id, $source_url) {
9197 // Get tag-role mappings
9198 $mappings = get_option('mxchat_tag_role_mappings', array());
9199
9200 if (empty($mappings)) {
9201 return;
9202 }
9203
9204 // Get all tags for the post
9205 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
9206
9207 if (empty($post_tags)) {
9208 return;
9209 }
9210
9211 // Determine the highest role restriction based on tags
9212 $highest_role = 'public';
9213 $role_hierarchy = array(
9214 'public' => 0,
9215 'logged_in' => 1,
9216 'subscriber' => 2,
9217 'contributor' => 3,
9218 'author' => 4,
9219 'editor' => 5,
9220 'administrator' => 6
9221 );
9222
9223 foreach ($post_tags as $tag_slug) {
9224 if (isset($mappings[$tag_slug])) {
9225 $role = $mappings[$tag_slug];
9226 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
9227 $highest_role = $role;
9228 }
9229 }
9230 }
9231
9232 // If no restricted tags found, return (leave as public)
9233 if ($highest_role === 'public') {
9234 return;
9235 }
9236
9237 // Update the role restriction
9238 global $wpdb;
9239
9240 // Check if using Pinecone
9241 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
9242 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9243
9244 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
9245 // Update Pinecone role restriction
9246 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
9247 $vector_id = md5($source_url);
9248
9249 $wpdb->replace(
9250 $roles_table,
9251 array(
9252 'vector_id' => $vector_id,
9253 'role_restriction' => $highest_role,
9254 'updated_at' => current_time('mysql')
9255 ),
9256 array('%s', '%s', '%s')
9257 );
9258 } else {
9259 // Update WordPress DB
9260 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9261
9262 $wpdb->update(
9263 $table_name,
9264 array('role_restriction' => $highest_role),
9265 array('source_url' => $source_url),
9266 array('%s'),
9267 array('%s')
9268 );
9269 }
9270
9271 // The entry's restriction just changed — keep the OpenAI Vector Store
9272 // mirror consistent: non-public pulls the file (file_search has no
9273 // per-role filtering), public re-mirrors it (plan 15b5c6).
9274 if (class_exists('MxChat_Vectorstore_Manager')) {
9275 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
9276 }
9277 }
9278
9279
9280 // ========================================
9281 // HELPER METHODS
9282 // ========================================
9283
9284 /**
9285 * Check if user has required permissions for content processing
9286 */
9287 private function mxchat_check_user_permissions() {
9288 if (!current_user_can('manage_options')) {
9289 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
9290 }
9291 }
9292
9293 /**
9294 * Validate nonce for security
9295 */
9296 private function mxchat_validate_nonce($nonce_name, $nonce_action) {
9297 if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
9298 wp_die(esc_html__('Security check failed.', 'mxchat'));
9299 }
9300 }
9301
9302 /**
9303 * Get embedding API credentials
9304 */
9305 private function mxchat_get_embedding_credentials() {
9306 $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
9307
9308 if (strpos($embedding_model, 'text-embedding-') !== false) {
9309 return array(
9310 'type' => 'openai',
9311 'api_key' => $this->options['api_key'] ?? ''
9312 );
9313 } elseif (strpos($embedding_model, 'voyage-') !== false) {
9314 return array(
9315 'type' => 'voyage',
9316 'api_key' => $this->options['voyage_api_key'] ?? ''
9317 );
9318 } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
9319 return array(
9320 'type' => 'gemini',
9321 'api_key' => $this->options['gemini_api_key'] ?? ''
9322 );
9323 }
9324
9325 return array('type' => 'unknown', 'api_key' => '');
9326 }
9327
9328 /**
9329 * Log processing errors
9330 */
9331 private function mxchat_log_processing_error($operation, $error_message) {
9332 //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
9333 }
9334
9335 /**
9336 * Set admin notice transient
9337 */
9338 private function mxchat_set_admin_notice($type, $message) {
9339 set_transient("mxchat_admin_notice_{$type}", $message, 30);
9340 }
9341
9342 /**
9343 * Get Pinecone manager instance for vector operations
9344 */
9345 private function mxchat_get_pinecone_manager() {
9346 return MxChat_Pinecone_Manager::get_instance();
9347 }
9348
9349
9350 // ========================================
9351 // DATABASE QUEUE TABLE MANAGEMENT
9352 // ========================================
9353
9354 /**
9355 * Create queue table on plugin activation
9356 * Call this from your plugin activation hook
9357 */
9358 public function mxchat_create_queue_table() {
9359 global $wpdb;
9360
9361 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9362 $charset_collate = $wpdb->get_charset_collate();
9363
9364 $sql = "CREATE TABLE IF NOT EXISTS $table_name (
9365 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
9366 queue_id varchar(64) NOT NULL,
9367 item_type varchar(20) NOT NULL,
9368 item_data longtext NOT NULL,
9369 status varchar(20) NOT NULL DEFAULT 'pending',
9370 bot_id varchar(50) NOT NULL DEFAULT 'default',
9371 priority int(11) NOT NULL DEFAULT 0,
9372 attempts int(11) NOT NULL DEFAULT 0,
9373 max_attempts int(11) NOT NULL DEFAULT 3,
9374 error_message text DEFAULT NULL,
9375 created_at datetime NOT NULL,
9376 started_at datetime DEFAULT NULL,
9377 completed_at datetime DEFAULT NULL,
9378 PRIMARY KEY (id),
9379 KEY queue_id (queue_id),
9380 KEY status (status),
9381 KEY item_type (item_type),
9382 KEY priority (priority)
9383 ) $charset_collate;";
9384
9385 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
9386 dbDelta($sql);
9387
9388 // Also create a meta table for queue metadata
9389 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
9390
9391 $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
9392 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
9393 queue_id varchar(64) NOT NULL,
9394 meta_key varchar(255) NOT NULL,
9395 meta_value longtext,
9396 PRIMARY KEY (id),
9397 KEY queue_id (queue_id),
9398 KEY meta_key (meta_key)
9399 ) $charset_collate;";
9400
9401 dbDelta($meta_sql);
9402 }
9403
9404 /**
9405 * Add items to the processing queue
9406 *
9407 * @param string $queue_id Unique identifier for this queue batch
9408 * @param string $item_type Type of item (url, pdf_page)
9409 * @param array $items Array of items to queue
9410 * @param string $bot_id Bot ID for processing
9411 * @return int Number of items queued
9412 */
9413 private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
9414 global $wpdb;
9415 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9416
9417 $queued_count = 0;
9418 $priority = 0;
9419
9420 foreach ($items as $item) {
9421 $result = $wpdb->insert(
9422 $table_name,
9423 array(
9424 'queue_id' => $queue_id,
9425 'item_type' => $item_type,
9426 'item_data' => wp_json_encode($item),
9427 'status' => 'pending',
9428 'bot_id' => $bot_id,
9429 'priority' => $priority,
9430 'attempts' => 0,
9431 'max_attempts' => 3,
9432 'created_at' => current_time('mysql')
9433 ),
9434 array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
9435 );
9436
9437 if ($result) {
9438 $queued_count++;
9439 }
9440
9441 $priority++; // Process in order
9442 }
9443
9444 return $queued_count;
9445 }
9446
9447 /**
9448 * Store queue metadata (total counts, source URL, etc.)
9449 */
9450 private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
9451 global $wpdb;
9452 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
9453
9454 // Check if meta exists
9455 $existing = $wpdb->get_var($wpdb->prepare(
9456 "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
9457 $queue_id,
9458 $meta_key
9459 ));
9460
9461 if ($existing) {
9462 // Update
9463 $wpdb->update(
9464 $meta_table,
9465 array('meta_value' => maybe_serialize($meta_value)),
9466 array('queue_id' => $queue_id, 'meta_key' => $meta_key),
9467 array('%s'),
9468 array('%s', '%s')
9469 );
9470 } else {
9471 // Insert
9472 $wpdb->insert(
9473 $meta_table,
9474 array(
9475 'queue_id' => $queue_id,
9476 'meta_key' => $meta_key,
9477 'meta_value' => maybe_serialize($meta_value)
9478 ),
9479 array('%s', '%s', '%s')
9480 );
9481 }
9482 }
9483
9484 /**
9485 * Get queue metadata
9486 */
9487 private function mxchat_get_queue_meta($queue_id, $meta_key) {
9488 global $wpdb;
9489 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
9490
9491 $value = $wpdb->get_var($wpdb->prepare(
9492 "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
9493 $queue_id,
9494 $meta_key
9495 ));
9496
9497 return maybe_unserialize($value);
9498 }
9499
9500 // ========================================
9501 // AJAX QUEUE PROCESSING HANDLERS
9502 // ========================================
9503
9504 /**
9505 * AJAX: Get next item from queue to process
9506 */
9507 public function ajax_mxchat_get_next_queue_item() {
9508 // Verify nonce and permissions
9509 check_ajax_referer('mxchat_queue_nonce', 'nonce');
9510
9511 if (!current_user_can('manage_options')) {
9512 wp_send_json_error('Unauthorized access');
9513 }
9514
9515 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
9516
9517 if (empty($queue_id)) {
9518 wp_send_json_error('Missing queue ID');
9519 }
9520
9521 global $wpdb;
9522 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9523
9524 // Get next pending item with retry logic for failed items
9525 $next_item = $wpdb->get_row($wpdb->prepare(
9526 "SELECT * FROM $table_name
9527 WHERE queue_id = %s
9528 AND status IN ('pending', 'failed')
9529 AND attempts < max_attempts
9530 ORDER BY priority ASC, id ASC
9531 LIMIT 1",
9532 $queue_id
9533 ));
9534
9535 if (!$next_item) {
9536 // No more items - queue complete
9537 wp_send_json_success(array(
9538 'complete' => true,
9539 'message' => 'Queue processing complete'
9540 ));
9541 }
9542
9543 // Mark item as processing
9544 $wpdb->update(
9545 $table_name,
9546 array(
9547 'status' => 'processing',
9548 'started_at' => current_time('mysql'),
9549 'attempts' => $next_item->attempts + 1
9550 ),
9551 array('id' => $next_item->id),
9552 array('%s', '%s', '%d'),
9553 array('%d')
9554 );
9555
9556 wp_send_json_success(array(
9557 'complete' => false,
9558 'item' => array(
9559 'id' => $next_item->id,
9560 'type' => $next_item->item_type,
9561 'data' => json_decode($next_item->item_data, true),
9562 'bot_id' => $next_item->bot_id,
9563 'attempt' => $next_item->attempts + 1
9564 )
9565 ));
9566 }
9567
9568 /**
9569 * AJAX: Process a single queue item
9570 */
9571 public function ajax_mxchat_process_queue_item() {
9572 // Verify nonce and permissions
9573 check_ajax_referer('mxchat_queue_nonce', 'nonce');
9574
9575 if (!current_user_can('manage_options')) {
9576 wp_send_json_error('Unauthorized access');
9577 }
9578
9579 $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
9580 $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
9581 $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
9582 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9583
9584 if (empty($item_id) || empty($item_type)) {
9585 wp_send_json_error('Missing item data');
9586 }
9587
9588 global $wpdb;
9589 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9590
9591 // Process based on item type
9592 try {
9593 set_time_limit(60); // Give processing 60 seconds
9594
9595 $result = false;
9596 $error_message = '';
9597
9598 // Read item directly from DB to get queue_id and preserve special chars in item_data
9599 // (POST round-trip through JS mangles characters like apostrophes in URLs)
9600 $db_item = $wpdb->get_row($wpdb->prepare(
9601 "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
9602 $item_id
9603 ));
9604 $item_queue_id = $db_item ? $db_item->queue_id : '';
9605 if ($db_item && !empty($db_item->item_data)) {
9606 $db_data = json_decode($db_item->item_data, true);
9607 if (is_array($db_data)) {
9608 $item_data = $db_data;
9609 }
9610 }
9611
9612 switch ($item_type) {
9613 case 'url':
9614 $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
9615 break;
9616
9617 case 'pdf_page':
9618 $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
9619 break;
9620
9621 default:
9622 throw new Exception('Unknown item type: ' . $item_type);
9623 }
9624
9625 if (is_wp_error($result)) {
9626 $error_code = $result->get_error_code();
9627 // Content errors (empty page, sanitization) are permanent — retrying won't help
9628 $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
9629 if (in_array($error_code, $permanent_codes)) {
9630 // Mark as permanently failed — set attempts = max_attempts so it won't be retried
9631 $current_item = $wpdb->get_row($wpdb->prepare(
9632 "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
9633 ));
9634 $wpdb->update(
9635 $table_name,
9636 array(
9637 'status' => 'failed',
9638 'error_message' => $result->get_error_message(),
9639 'attempts' => $current_item ? $current_item->max_attempts : 3
9640 ),
9641 array('id' => $item_id),
9642 array('%s', '%s', '%d'),
9643 array('%d')
9644 );
9645 wp_send_json_error(array(
9646 'message' => $result->get_error_message(),
9647 'permanent_failure' => true,
9648 'item_id' => $item_id
9649 ));
9650 return;
9651 }
9652 throw new Exception($result->get_error_message());
9653 }
9654
9655 if ($result === false) {
9656 throw new Exception('Processing returned false - item may be empty or invalid');
9657 }
9658
9659 // Mark as completed
9660 $wpdb->update(
9661 $table_name,
9662 array(
9663 'status' => 'completed',
9664 'completed_at' => current_time('mysql'),
9665 'error_message' => null
9666 ),
9667 array('id' => $item_id),
9668 array('%s', '%s', '%s'),
9669 array('%d')
9670 );
9671
9672 wp_send_json_success(array(
9673 'processed' => true,
9674 'item_id' => $item_id,
9675 'message' => 'Item processed successfully'
9676 ));
9677
9678 } catch (Exception $e) {
9679 $error_message = $e->getMessage();
9680
9681 // Get current attempt count
9682 $item = $wpdb->get_row($wpdb->prepare(
9683 "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
9684 $item_id
9685 ));
9686
9687 // Check if we've exhausted retries
9688 if ($item && $item->attempts >= $item->max_attempts) {
9689 // Permanently failed
9690 $wpdb->update(
9691 $table_name,
9692 array(
9693 'status' => 'failed',
9694 'error_message' => $error_message
9695 ),
9696 array('id' => $item_id),
9697 array('%s', '%s'),
9698 array('%d')
9699 );
9700
9701 wp_send_json_error(array(
9702 'message' => 'Item failed after maximum attempts: ' . $error_message,
9703 'permanent_failure' => true,
9704 'item_id' => $item_id
9705 ));
9706 } else {
9707 // Mark for retry
9708 $wpdb->update(
9709 $table_name,
9710 array(
9711 'status' => 'failed',
9712 'error_message' => $error_message
9713 ),
9714 array('id' => $item_id),
9715 array('%s', '%s'),
9716 array('%d')
9717 );
9718
9719 wp_send_json_error(array(
9720 'message' => 'Item processing failed, will retry: ' . $error_message,
9721 'can_retry' => true,
9722 'item_id' => $item_id,
9723 'attempts' => $item ? $item->attempts : 0
9724 ));
9725 }
9726 }
9727 }
9728
9729 /**
9730 * Process a URL from the queue
9731 */
9732 private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
9733 $url = isset($item_data['url']) ? $item_data['url'] : '';
9734
9735 if (empty($url)) {
9736 return new WP_Error('invalid_url', 'URL is empty');
9737 }
9738
9739 // Get bot-specific embedding decision early (needed for both paths) —
9740 // custom-provider-aware (plan cbd5fd). Error code preserved.
9741 $bot_options = $this->get_bot_options($bot_id);
9742 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
9743
9744 $preflight = MxChat_Utils::embedding_preflight($options);
9745 if (!$preflight['ok']) {
9746 return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
9747 }
9748 $api_key = $preflight['api_key'];
9749
9750 // Check if this is a WooCommerce product URL and WooCommerce is active
9751 $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
9752 $content_type = $is_product_url ? 'product' : 'url';
9753
9754 // Try to get WooCommerce product data if it's a product URL
9755 if ($is_product_url && class_exists('WooCommerce')) {
9756 $product_content = $this->mxchat_extract_woocommerce_product_content($url);
9757
9758 if (!empty($product_content)) {
9759 // Successfully extracted WooCommerce product data with pricing
9760 $result = MxChat_Utils::submit_content_to_db(
9761 $product_content,
9762 $url,
9763 $api_key,
9764 null,
9765 $bot_id,
9766 'product'
9767 );
9768 return $result;
9769 }
9770 // If WooCommerce extraction failed, fall through to HTML extraction
9771 }
9772
9773 // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
9774 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
9775 $response = wp_remote_get($url, array(
9776 'timeout' => $is_likely_pdf ? 120 : 30,
9777 'redirection' => 5,
9778 'user-agent' => mxchat_ingest_user_agent(),
9779 ));
9780
9781 if (is_wp_error($response)) {
9782 return $response;
9783 }
9784
9785 $response_code = wp_remote_retrieve_response_code($response);
9786 if ($response_code !== 200) {
9787 return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
9788 }
9789
9790 // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
9791 if ($this->mxchat_is_pdf_url($url, $response)) {
9792 return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
9793 }
9794
9795 $html = wp_remote_retrieve_body($response);
9796
9797 if (empty($html)) {
9798 return new WP_Error('empty_response', 'Empty response body');
9799 }
9800
9801 // Extract and sanitize content
9802 $content = $this->mxchat_extract_main_content($html);
9803 $sanitized = $this->mxchat_sanitize_content_for_api($content);
9804
9805 if (empty($sanitized)) {
9806 // Not an error - just no content found (maybe a redirect or empty page)
9807 return false;
9808 }
9809
9810 // Submit to database with content_type
9811 $result = MxChat_Utils::submit_content_to_db(
9812 $sanitized,
9813 $url,
9814 $api_key,
9815 null,
9816 $bot_id,
9817 $content_type
9818 );
9819
9820 return $result;
9821 }
9822
9823 /**
9824 * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
9825 * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
9826 * and adds pdf_page items to the same queue so they process with full progress tracking.
9827 */
9828 private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
9829 set_time_limit(120); // PDFs need extra time for download + parsing
9830
9831 $upload_dir = wp_upload_dir();
9832 $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
9833 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
9834
9835 $response_body = wp_remote_retrieve_body($response);
9836 if (empty($response_body)) {
9837 return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
9838 }
9839
9840 if (!wp_mkdir_p(dirname($pdf_path))) {
9841 return new WP_Error('dir_error', 'Failed to create upload directory');
9842 }
9843
9844 file_put_contents($pdf_path, $response_body);
9845
9846 if (!file_exists($pdf_path)) {
9847 return new WP_Error('save_error', 'Failed to save PDF file');
9848 }
9849
9850 try {
9851 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
9852
9853 if ($total_pages === false || $total_pages < 1) {
9854 wp_delete_file($pdf_path);
9855 return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
9856 }
9857
9858 // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
9859 $pages = array();
9860 for ($i = 1; $i <= $total_pages; $i++) {
9861 $pages[] = array(
9862 'pdf_path' => $pdf_path,
9863 'pdf_url' => $pdf_url,
9864 'page_number' => $i,
9865 'total_pages' => $total_pages
9866 );
9867 }
9868
9869 // Add pdf_page items to the SAME queue so the JS picks them up automatically
9870 if (!empty($queue_id)) {
9871 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
9872 } else {
9873 // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
9874 $new_queue_id = 'pdf_' . md5($pdf_url . time());
9875 $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
9876 $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
9877 $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
9878 $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
9879 $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
9880 $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
9881 $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
9882 }
9883
9884 if ($queued_count === 0) {
9885 wp_delete_file($pdf_path);
9886 return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
9887 }
9888
9889 // Return true so the original URL item is marked complete
9890 // The new pdf_page items will be processed in subsequent batches
9891 return true;
9892
9893 } catch (Exception $e) {
9894 if (file_exists($pdf_path)) {
9895 wp_delete_file($pdf_path);
9896 }
9897 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
9898 }
9899 }
9900
9901 /**
9902 * Legacy: Process a PDF URL inline during sitemap queue processing.
9903 * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
9904 */
9905 private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
9906 set_time_limit(120); // PDFs need more time — downloading + parsing all pages
9907
9908 $upload_dir = wp_upload_dir();
9909 $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
9910 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
9911
9912 $response_body = wp_remote_retrieve_body($response);
9913 if (empty($response_body)) {
9914 return new WP_Error('empty_pdf', 'Empty PDF response');
9915 }
9916
9917 if (!wp_mkdir_p(dirname($pdf_path))) {
9918 return new WP_Error('dir_error', 'Failed to create upload directory');
9919 }
9920
9921 file_put_contents($pdf_path, $response_body);
9922
9923 if (!file_exists($pdf_path)) {
9924 return new WP_Error('save_error', 'Failed to save PDF file');
9925 }
9926
9927 try {
9928 mxchat_load_pdf_parser();
9929 $parser = new \Smalot\PdfParser\Parser();
9930 $pdf = $parser->parseFile($pdf_path);
9931 $pages = $pdf->getPages();
9932 $total_pages = count($pages);
9933
9934 if ($total_pages < 1) {
9935 wp_delete_file($pdf_path);
9936 return new WP_Error('no_pages', 'PDF has no pages');
9937 }
9938
9939 $processed = 0;
9940 $skipped_pages = array();
9941
9942 for ($i = 0; $i < $total_pages; $i++) {
9943 $page_num = $i + 1;
9944 $text = $pages[$i]->getText();
9945 $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_import page ' . $page_num);
9946 if (empty($text)) {
9947 $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
9948 continue;
9949 }
9950
9951 $sanitized = $this->mxchat_sanitize_content_for_api($text);
9952 if (empty($sanitized)) {
9953 $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
9954 continue;
9955 }
9956
9957 $metadata = array(
9958 'document_type' => 'pdf',
9959 'total_pages' => $total_pages,
9960 'current_page' => $page_num,
9961 'source_url' => $pdf_url,
9962 );
9963
9964 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
9965 $page_url = esc_url($pdf_url . '#page=' . $page_num);
9966
9967 MxChat_Utils::submit_content_to_db(
9968 $content_with_metadata,
9969 $page_url,
9970 $api_key,
9971 null,
9972 $bot_id,
9973 'pdf'
9974 );
9975
9976 $processed++;
9977 }
9978
9979 // Clean up the temp PDF file
9980 wp_delete_file($pdf_path);
9981
9982 if (!empty($skipped_pages)) {
9983 error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
9984 }
9985
9986 return $processed > 0 ? true : false;
9987
9988 } catch (Exception $e) {
9989 if (file_exists($pdf_path)) {
9990 wp_delete_file($pdf_path);
9991 }
9992 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
9993 }
9994 }
9995
9996 /**
9997 * Extract WooCommerce product content including pricing
9998 *
9999 * @param string $url The product URL
10000 * @return string|false Product content with pricing, or false if not found
10001 */
10002 private function mxchat_extract_woocommerce_product_content($url) {
10003 // Try to get product ID from URL
10004 $product_id = url_to_postid($url);
10005
10006 // If url_to_postid fails, try to extract from URL pattern
10007 if (!$product_id) {
10008 $product_slug = '';
10009
10010 // Handle pretty permalinks: /product/product-name/
10011 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
10012 $product_slug = $matches[1];
10013 }
10014
10015 if (!empty($product_slug)) {
10016 $product_post = get_page_by_path($product_slug, OBJECT, 'product');
10017 if ($product_post) {
10018 $product_id = $product_post->ID;
10019 }
10020 }
10021 }
10022
10023 if (!$product_id) {
10024 return false;
10025 }
10026
10027 // Get WooCommerce product object
10028 $product = wc_get_product($product_id);
10029
10030 if (!$product) {
10031 return false;
10032 }
10033
10034 // Build product content via the shared WC-object assembler (a3d60c) — same
10035 // body as the auto-sync product writer, so the two paths can never drift.
10036 $content = $this->mxchat_prepare_product_content_for_indexing($product);
10037
10038 return $this->mxchat_sanitize_content_for_api($content);
10039 }
10040
10041 /**
10042 * Process a PDF page from the queue
10043 */
10044 private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
10045 $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
10046 $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
10047 $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
10048 $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
10049
10050 if (empty($pdf_path) || !file_exists($pdf_path)) {
10051 return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
10052 }
10053
10054 if ($page_number < 1) {
10055 return new WP_Error('invalid_page', 'Invalid page number');
10056 }
10057
10058 try {
10059 mxchat_load_pdf_parser();
10060 $parser = new \Smalot\PdfParser\Parser();
10061 $pdf = $parser->parseFile($pdf_path);
10062 $pages = $pdf->getPages();
10063
10064 if (!isset($pages[$page_number - 1])) {
10065 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
10066 }
10067
10068 $text = $pages[$page_number - 1]->getText();
10069 $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_page page ' . $page_number);
10070
10071 if (empty($text)) {
10072 return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
10073 }
10074
10075 $sanitized = $this->mxchat_sanitize_content_for_api($text);
10076
10077 if (empty($sanitized)) {
10078 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');
10079 }
10080
10081 // Create metadata
10082 $metadata = array(
10083 'document_type' => 'pdf',
10084 'total_pages' => $total_pages,
10085 'current_page' => $page_number,
10086 'source_url' => $pdf_url
10087 );
10088
10089 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
10090 $page_url = esc_url($pdf_url . "#page=" . $page_number);
10091
10092 // Get bot-specific embedding decision — custom-provider-aware
10093 // (plan cbd5fd). Error code preserved.
10094 $bot_options = $this->get_bot_options($bot_id);
10095 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
10096
10097 $preflight = MxChat_Utils::embedding_preflight($options);
10098 if (!$preflight['ok']) {
10099 return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
10100 }
10101 $api_key = $preflight['api_key'];
10102
10103 // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
10104 $result = MxChat_Utils::submit_content_to_db(
10105 $content_with_metadata,
10106 $page_url,
10107 $api_key,
10108 null,
10109 $bot_id,
10110 'pdf'
10111 );
10112
10113 return $result;
10114
10115 } catch (Exception $e) {
10116 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
10117 }
10118 }
10119
10120 /**
10121 * AJAX: Get queue processing status
10122 */
10123 public function ajax_mxchat_get_queue_status() {
10124 // Verify nonce and permissions
10125 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10126
10127 if (!current_user_can('manage_options')) {
10128 wp_send_json_error('Unauthorized access');
10129 }
10130
10131 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10132
10133 if (empty($queue_id)) {
10134 wp_send_json_error('Missing queue ID');
10135 }
10136
10137 global $wpdb;
10138 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10139
10140 // Get counts by status
10141 $counts = $wpdb->get_results($wpdb->prepare(
10142 "SELECT status, COUNT(*) as count
10143 FROM $table_name
10144 WHERE queue_id = %s
10145 GROUP BY status",
10146 $queue_id
10147 ), OBJECT_K);
10148
10149 $total = 0;
10150 $completed = 0;
10151 $failed = 0;
10152 $processing = 0;
10153 $pending = 0;
10154
10155 foreach ($counts as $status => $data) {
10156 $count = absint($data->count);
10157 $total += $count;
10158
10159 switch ($status) {
10160 case 'completed':
10161 $completed = $count;
10162 break;
10163 case 'failed':
10164 $failed = $count;
10165 break;
10166 case 'processing':
10167 $processing = $count;
10168 break;
10169 case 'pending':
10170 $pending = $count;
10171 break;
10172 }
10173 }
10174
10175 // Calculate percentage
10176 $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
10177
10178 // Get failed items details (include all failed items, not just those that exhausted retries)
10179 $failed_items = array();
10180 if ($failed > 0) {
10181 $failed_items = $wpdb->get_results($wpdb->prepare(
10182 "SELECT item_type, item_data, error_message, attempts
10183 FROM $table_name
10184 WHERE queue_id = %s
10185 AND status = 'failed'
10186 ORDER BY id DESC
10187 LIMIT 50",
10188 $queue_id
10189 ));
10190 }
10191
10192 // Get queue metadata
10193 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
10194 $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
10195
10196 // Determine if queue is complete
10197 $is_complete = ($pending === 0 && $processing === 0);
10198
10199 wp_send_json_success(array(
10200 'queue_id' => $queue_id,
10201 'queue_type' => $queue_type,
10202 'source_url' => $source_url,
10203 'total' => $total,
10204 'completed' => $completed,
10205 'failed' => $failed,
10206 'processing' => $processing,
10207 'pending' => $pending,
10208 'percentage' => $percentage,
10209 'is_complete' => $is_complete,
10210 'failed_items' => $failed_items,
10211 'status' => $is_complete ? 'complete' : 'processing'
10212 ));
10213 }
10214
10215 /**
10216 * AJAX: Clear completed queue
10217 */
10218 public function ajax_mxchat_clear_queue() {
10219 // Verify nonce and permissions
10220 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10221
10222 if (!current_user_can('manage_options')) {
10223 wp_send_json_error('Unauthorized access');
10224 }
10225
10226 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10227
10228 if (empty($queue_id)) {
10229 wp_send_json_error('Missing queue ID');
10230 }
10231
10232 global $wpdb;
10233 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10234 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10235
10236 // Delete queue items
10237 $wpdb->delete(
10238 $table_name,
10239 array('queue_id' => $queue_id),
10240 array('%s')
10241 );
10242
10243 // Delete queue metadata
10244 $wpdb->delete(
10245 $meta_table,
10246 array('queue_id' => $queue_id),
10247 array('%s')
10248 );
10249
10250 wp_send_json_success(array(
10251 'message' => 'Queue cleared successfully'
10252 ));
10253 }
10254
10255 /**
10256 * AJAX: Retry failed items in queue
10257 */
10258 public function ajax_mxchat_retry_failed() {
10259 // Verify nonce and permissions
10260 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10261
10262 if (!current_user_can('manage_options')) {
10263 wp_send_json_error('Unauthorized access');
10264 }
10265
10266 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10267
10268 if (empty($queue_id)) {
10269 wp_send_json_error('Missing queue ID');
10270 }
10271
10272 global $wpdb;
10273 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10274
10275 // Reset failed items to pending and reset attempt count
10276 $updated = $wpdb->update(
10277 $table_name,
10278 array(
10279 'status' => 'pending',
10280 'attempts' => 0,
10281 'error_message' => null
10282 ),
10283 array(
10284 'queue_id' => $queue_id,
10285 'status' => 'failed'
10286 ),
10287 array('%s', '%d', '%s'),
10288 array('%s', '%s')
10289 );
10290
10291 wp_send_json_success(array(
10292 'message' => 'Reset ' . $updated . ' failed items for retry',
10293 'reset_count' => $updated
10294 ));
10295 }
10296
10297
10298 public function ajax_mxchat_mark_queue_complete() {
10299 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10300
10301 if (!current_user_can('manage_options')) {
10302 wp_send_json_error('Unauthorized access');
10303 }
10304
10305 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10306
10307 if (empty($queue_id)) {
10308 wp_send_json_error('Missing queue ID');
10309 }
10310
10311 // Clear active queue transients
10312 if (strpos($queue_id, 'sitemap_') === 0) {
10313 delete_transient('mxchat_active_queue_sitemap');
10314 } else if (strpos($queue_id, 'pdf_') === 0) {
10315 delete_transient('mxchat_active_queue_pdf');
10316 }
10317
10318 wp_send_json_success(array('message' => 'Queue marked as complete'));
10319 }
10320
10321
10322 // ========================================
10323 // STATIC ACCESS METHODS
10324 // ========================================
10325
10326 /**
10327 * Get singleton instance
10328 */
10329 public static function get_instance() {
10330 static $instance = null;
10331 if ($instance === null) {
10332 $instance = new self();
10333 }
10334 return $instance;
10335 }
10336 }
10337
10338 // Initialize the Knowledge manager
10339 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();